mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 10:47:36 +00:00
Binary file not shown.
@@ -1 +0,0 @@
|
||||
6449a4e1126f428765d0bb9459342a079d2bc1d4c372b676d579f1c48a2d7d4a ProxMenux-1.2.5.AppImage
|
||||
@@ -2717,6 +2717,11 @@ interface BorgRepo {
|
||||
name: string
|
||||
repository: string
|
||||
ssh_key_path?: string
|
||||
// Parsed from `ssh://user@host[:port]/path`; 22 for URLs without an
|
||||
// explicit port and 0 for local-mode targets. Newer backends ship
|
||||
// this; older ones don't and the frontend falls back to re-parsing
|
||||
// the URL on the fly.
|
||||
ssh_port?: number
|
||||
// Encryption + saved-passphrase metadata. Newer backends ship these;
|
||||
// older deployments without the fields default to "repokey" (the
|
||||
// shell installer's historical default) and unknown-passphrase.
|
||||
@@ -6092,6 +6097,11 @@ function AddDestinationDialog({
|
||||
const [borgMode, setBorgMode] = useState<"local" | "ssh">("local")
|
||||
const [borgSshUser, setBorgSshUser] = useState("borg")
|
||||
const [borgSshHost, setBorgSshHost] = useState("")
|
||||
// Custom SSH port for NAS-style hosts that expose SSH on non-22.
|
||||
// Kept as string in state so the input can start empty; converted to
|
||||
// integer at submit time. Empty / "22" means "use the default port"
|
||||
// and is omitted from the ssh:// URL by the backend.
|
||||
const [borgSshPort, setBorgSshPort] = useState("22")
|
||||
const [borgSshRemotePath, setBorgSshRemotePath] = useState("")
|
||||
const [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg")
|
||||
const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null)
|
||||
@@ -6148,26 +6158,32 @@ function AddDestinationDialog({
|
||||
setUsername(editing.username || "root@pam")
|
||||
setFingerprint(editing.fingerprint || "")
|
||||
setBorgRepo(""); setBorgMode("local"); setBorgSshUser("borg")
|
||||
setBorgSshHost(""); setBorgSshRemotePath("")
|
||||
setBorgSshHost(""); setBorgSshPort("22"); setBorgSshRemotePath("")
|
||||
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
||||
setBorgEncryptionEnabled(true); setLocalPath("")
|
||||
return
|
||||
}
|
||||
if (editing && editing.kind === "borg") {
|
||||
const repo = editing.repository || ""
|
||||
const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/)
|
||||
// Match ssh://user@host[:port]/path — the port group is optional.
|
||||
// Group 3 captures the numeric port when present, group 4 the path.
|
||||
const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/:]+)(?::(\d+))?\/(.+)$/)
|
||||
setName(editing.name)
|
||||
if (ssh) {
|
||||
setBorgMode("ssh")
|
||||
setBorgSshUser(ssh[1])
|
||||
setBorgSshHost(ssh[2])
|
||||
setBorgSshRemotePath(`/${ssh[3]}`)
|
||||
// Prefer the port already parsed by the backend (ssh_port); fall
|
||||
// back to the URL match; default 22 when neither is set.
|
||||
const port = editing.ssh_port ?? (ssh[3] ? Number(ssh[3]) : 22)
|
||||
setBorgSshPort(String(port || 22))
|
||||
setBorgSshRemotePath(`/${ssh[4]}`)
|
||||
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
|
||||
setBorgRepo("")
|
||||
} else {
|
||||
setBorgMode("local")
|
||||
setBorgRepo(repo)
|
||||
setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshRemotePath("")
|
||||
setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshPort("22"); setBorgSshRemotePath("")
|
||||
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
||||
}
|
||||
const mode = editing.encrypt_mode || "repokey"
|
||||
@@ -6186,6 +6202,7 @@ function AddDestinationDialog({
|
||||
setBorgMode("local")
|
||||
setBorgSshUser("borg")
|
||||
setBorgSshHost("")
|
||||
setBorgSshPort("22")
|
||||
setBorgSshRemotePath("")
|
||||
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
||||
setBorgEncryptionEnabled(true)
|
||||
@@ -6274,6 +6291,12 @@ function AddDestinationDialog({
|
||||
body.ssh_host = borgSshHost.trim()
|
||||
body.ssh_remote_path = borgSshRemotePath.trim()
|
||||
if (borgSshKeyPath.trim()) body.ssh_key_path = borgSshKeyPath.trim()
|
||||
// Only send port when it's non-default; the backend leaves 22
|
||||
// out of the persisted URL so existing targets stay identical.
|
||||
const portNum = parseInt(borgSshPort.trim(), 10)
|
||||
if (Number.isFinite(portNum) && portNum > 0 && portNum !== 22) {
|
||||
body.ssh_port = portNum
|
||||
}
|
||||
}
|
||||
const resp = await fetchApi<{ repo?: string }>("/api/host-backups/destinations/borg", {
|
||||
method: "POST",
|
||||
@@ -6409,9 +6432,23 @@ function AddDestinationDialog({
|
||||
{t("backup.destinations.sshUserHelpBefore")} <code className="font-mono">borg serve</code>. {t("backup.destinations.sshUserHelpAfter")} <code className="font-mono">borg</code>, {t("backup.destinations.not")} <code className="font-mono">root</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="borgSshHost">{t("backup.fields.sshHostOrIp")}</Label>
|
||||
<Input id="borgSshHost" value={borgSshHost} onChange={(e) => setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" />
|
||||
<div className="grid grid-cols-[1fr_100px] gap-3">
|
||||
<div>
|
||||
<Label htmlFor="borgSshHost">{t("backup.fields.sshHostOrIp")}</Label>
|
||||
<Input id="borgSshHost" value={borgSshHost} onChange={(e) => setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="borgSshPort">{t("backup.fields.sshPort")}</Label>
|
||||
<Input
|
||||
id="borgSshPort"
|
||||
value={borgSshPort}
|
||||
onChange={(e) => setBorgSshPort(e.target.value.replace(/[^\d]/g, ""))}
|
||||
className="font-mono mt-1"
|
||||
placeholder="22"
|
||||
inputMode="numeric"
|
||||
maxLength={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="borgSshPath">{t("backup.fields.remoteRepositoryPath")}</Label>
|
||||
|
||||
@@ -355,6 +355,15 @@ function suggestPackageName(name: string) {
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
// The argv editors use a comma-separated display value, while the API stores
|
||||
// each argument as an array item. Keep this conversion separate from the text
|
||||
// shown in the controlled input: normalising the visible value on every
|
||||
// keystroke would remove a newly typed comma or trailing space before the user
|
||||
// can enter the next argument.
|
||||
function parseArgvInput(value: string): string[] {
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) {
|
||||
const t = useT()
|
||||
const isLightTheme = useIsLightTheme()
|
||||
@@ -371,6 +380,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
||||
// Editor state
|
||||
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
|
||||
const [binaryArgsInput, setBinaryArgsInput] = useState("")
|
||||
const [commandArgvInput, setCommandArgvInput] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testingDetector, setTestingDetector] = useState(false)
|
||||
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
|
||||
@@ -501,6 +512,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
}
|
||||
return t("vmLxc.appEditor.upstreamErrorNetwork", { detail })
|
||||
}
|
||||
if (lower.includes("github rate limited")) {
|
||||
return t("vmLxc.appEditor.upstreamErrorGithubRateLimit")
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -742,6 +756,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
setShowAdvanced(false)
|
||||
}
|
||||
}
|
||||
setBinaryArgsInput((seed.binary_args || []).join(", "))
|
||||
setCommandArgvInput((seed.command_argv || []).join(", "))
|
||||
setEditing({ appId: existing?.id || null, draft: seed })
|
||||
setDetectorTest(null)
|
||||
setError(null)
|
||||
@@ -749,6 +765,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditing(null)
|
||||
setBinaryArgsInput("")
|
||||
setCommandArgvInput("")
|
||||
setDetectorTest(null)
|
||||
setError(null)
|
||||
}
|
||||
@@ -771,6 +789,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
setSidecar(r)
|
||||
setLxcAppsCached(vmid, r, suggestions)
|
||||
setEditing(null)
|
||||
setBinaryArgsInput("")
|
||||
setCommandArgvInput("")
|
||||
onChange?.()
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
||||
@@ -1241,6 +1261,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
patch.distribution = t.distribution || ""
|
||||
patch.container_name = t.container_name || ""
|
||||
patch.label = t.label || ""
|
||||
patch.command_argv = t.command_argv || []
|
||||
patch.installed_regex = t.installed_regex || ""
|
||||
patch.upstream_type = (t as any).upstream_type || (t.repo ? "github" : "")
|
||||
patch.repo = t.repo || ""
|
||||
@@ -1249,6 +1270,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
patch.upstream_json_path = (t as any).upstream_json_path || ""
|
||||
patch.docker_image = (t as any).docker_image || ""
|
||||
patch.tag_regex = t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
setBinaryArgsInput((t.binary_args || []).join(", "))
|
||||
setCommandArgvInput((t.command_argv || []).join(", "))
|
||||
setShowAdvanced(true)
|
||||
}
|
||||
setEditing((prev) => prev ? { ...prev, draft: { ...prev.draft, ...patch } } : prev)
|
||||
@@ -1713,10 +1736,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
<Label htmlFor="app-de-args">{t("vmLxc.appEditor.binaryArgsLabel")}</Label>
|
||||
<Input
|
||||
id="app-de-args"
|
||||
value={(draft.binary_args || []).join(", ")}
|
||||
onChange={(e) => setField({
|
||||
binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
|
||||
})}
|
||||
value={binaryArgsInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setBinaryArgsInput(value)
|
||||
setField({ binary_args: parseArgvInput(value) })
|
||||
}}
|
||||
placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
@@ -1732,10 +1757,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
||||
<Label htmlFor="app-cmd-argv">{t("vmLxc.appEditor.commandLabel")}</Label>
|
||||
<Input
|
||||
id="app-cmd-argv"
|
||||
value={(draft.command_argv || []).join(", ")}
|
||||
onChange={(e) => setField({
|
||||
command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
|
||||
})}
|
||||
value={commandArgvInput}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setCommandArgvInput(value)
|
||||
setField({ command_argv: parseArgvInput(value) })
|
||||
}}
|
||||
placeholder={t("vmLxc.appEditor.commandPlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
|
||||
@@ -340,6 +340,12 @@ export function NotificationSettings() {
|
||||
const [testingAI, setTestingAI] = useState(false)
|
||||
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
|
||||
const [providerModels, setProviderModels] = useState<string[]>([])
|
||||
// Surfaces the message the backend returns when the models fetch
|
||||
// fails (bad key, unreachable endpoint, SSRF guard blocking the URL,
|
||||
// network error). Cleared on the next successful fetch, on provider
|
||||
// change and on unmount. Without this the dropdown just goes empty
|
||||
// and the user has no clue why (issue #325).
|
||||
const [providerModelsError, setProviderModelsError] = useState<string | null>(null)
|
||||
const [loadingProviderModels, setLoadingProviderModels] = useState(false)
|
||||
const [showCustomPromptInfo, setShowCustomPromptInfo] = useState(false)
|
||||
const [editingCustomPrompt, setEditingCustomPrompt] = useState(false)
|
||||
@@ -992,11 +998,12 @@ export function NotificationSettings() {
|
||||
}
|
||||
|
||||
setLoadingProviderModels(true)
|
||||
setProviderModelsError(null)
|
||||
try {
|
||||
const data = await fetchApi<{ success: boolean; models: string[]; recommended: string; message: string }>("/api/notifications/provider-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
api_key: apiKey,
|
||||
ollama_url: config.ai_ollama_url,
|
||||
@@ -1009,8 +1016,8 @@ export function NotificationSettings() {
|
||||
updateConfig(prev => {
|
||||
if (!prev.ai_model || !data.models.includes(prev.ai_model)) {
|
||||
const modelToSelect = data.recommended || data.models[0]
|
||||
return {
|
||||
...prev,
|
||||
return {
|
||||
...prev,
|
||||
ai_model: modelToSelect,
|
||||
ai_models: { ...prev.ai_models, [provider]: modelToSelect }
|
||||
}
|
||||
@@ -1019,9 +1026,13 @@ export function NotificationSettings() {
|
||||
})
|
||||
} else {
|
||||
setProviderModels([])
|
||||
// Surface the backend's error message so the user can act on
|
||||
// it (bad key, SSRF-blocked URL, unreachable endpoint …).
|
||||
setProviderModelsError(data.message || t("settings.notifications.ai.loadModelsFailed"))
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
setProviderModels([])
|
||||
setProviderModelsError(err instanceof Error ? err.message : t("settings.notifications.ai.loadModelsFailed"))
|
||||
} finally {
|
||||
setLoadingProviderModels(false)
|
||||
}
|
||||
@@ -2427,6 +2438,12 @@ export function NotificationSettings() {
|
||||
{providerModels.length > 0 && (
|
||||
<p className="text-xs text-green-500">{t("settings.notifications.ai.modelsAvailable", { count: providerModels.length })}</p>
|
||||
)}
|
||||
{/* Surface the backend's error message when a Load attempt
|
||||
returns empty — silent dropdown was invisible to the user
|
||||
(issue #325). Cleared when the next successful load lands. */}
|
||||
{providerModels.length === 0 && providerModelsError && !loadingProviderModels && (
|
||||
<p className="text-xs text-red-400">{providerModelsError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt Mode section */}
|
||||
|
||||
@@ -18,6 +18,23 @@ interface ReleaseNote {
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.2.6": {
|
||||
date: "September 2, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Borg remote target — the Add Borg destination dialog in the Monitor and the shell TUI (menu → Host Backup → New Borg target) accept a custom SSH port; the default stays at 22 and existing entries created without a port keep working. BORG_RSH, the auto key install flow and the capacity probe all honour the custom port (suggested by @songochain in discussion #236).",
|
||||
"GitHub API — Settings → GitHub API accepts an optional personal access token for release and tag checks when GitHub's anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser (suggested by @SystemIdleProcess in discussion #306).",
|
||||
],
|
||||
changed: [
|
||||
"Notification delivery is atomic — events reserve their deduplication fingerprint before AI processing and channel delivery, so concurrent collectors or parallel Monitor processes cannot send the same event twice. The reservation is shared through SQLite and released when no channel succeeds, preserving retries after temporary transport failures.",
|
||||
"Native Proxmox replication failure notifications now resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job's failures deduplicate independently (reported by Ale R.).",
|
||||
],
|
||||
fixed: [
|
||||
"AI Assistant custom OpenAI endpoint — endpoints reachable on private IPs, loopback or Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies…) are accepted when loading the model catalogue and validating the AI configuration. The dropdown surfaces the reason returned by the server (or the underlying network error) directly under the Load button, in every Monitor language (#325, reported by @jorgeffonte).",
|
||||
"Secure Gateway wizard — Alpine template download and local template selection filter by the host's real architecture (via dpkg --print-architecture, falling back to uname -m); pct create is invoked with an explicit --arch so container metadata matches the host on both x86_64 and arm64 (#324, reported by @N0X4DD0).",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.5": {
|
||||
date: "September 1, 2026",
|
||||
changes: {
|
||||
@@ -289,28 +306,33 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.appsDashboard",
|
||||
text: "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
|
||||
key: "releaseNotes.currentFeatures.aiCustomEndpoint",
|
||||
text: "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
},
|
||||
{
|
||||
icon: <Cpu className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.lxcAppsUpdates",
|
||||
text: "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
|
||||
icon: <Wrench className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.secureGatewayArch",
|
||||
text: "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.appCatalog",
|
||||
text: "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.atomicNotifications",
|
||||
text: "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
},
|
||||
{
|
||||
icon: <Languages className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.multilingual",
|
||||
text: "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.borgSshPort",
|
||||
text: "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
},
|
||||
{
|
||||
icon: <Server className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.nvidiaMultiGpu",
|
||||
text: "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.githubToken",
|
||||
text: "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.replicationContext",
|
||||
text: "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.).",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
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, BellOff, Globe2 } 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, Globe2, Github } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { NotificationSettings } from "./notification-settings"
|
||||
@@ -392,6 +392,17 @@ export function Settings() {
|
||||
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
|
||||
const [savingInterface, setSavingInterface] = useState<string | null>(null)
|
||||
|
||||
// Optional GitHub API authentication for app release/tag checks. The
|
||||
// backend only returns whether a token exists; the secret itself never
|
||||
// leaves the host after it has been saved.
|
||||
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false)
|
||||
const [githubTokenLoading, setGithubTokenLoading] = useState(true)
|
||||
const [githubTokenEditMode, setGithubTokenEditMode] = useState(false)
|
||||
const [githubTokenDraft, setGithubTokenDraft] = useState("")
|
||||
const [githubTokenSaving, setGithubTokenSaving] = useState(false)
|
||||
const [githubTokenSaved, setGithubTokenSaved] = useState(false)
|
||||
const [githubTokenError, setGithubTokenError] = useState("")
|
||||
|
||||
// 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`.
|
||||
@@ -451,6 +462,63 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadGithubTokenStatus = async () => {
|
||||
setGithubTokenLoading(true)
|
||||
try {
|
||||
const data = await fetchApi<{ configured: boolean }>("/api/apps/github-token")
|
||||
setGithubTokenConfigured(!!data.configured)
|
||||
setGithubTokenError("")
|
||||
} catch (err) {
|
||||
console.error("Failed to load GitHub API token status:", err)
|
||||
setGithubTokenError(t("settings.githubApi.loadFailed"))
|
||||
} finally {
|
||||
setGithubTokenLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveGithubToken = async () => {
|
||||
const token = githubTokenDraft.trim()
|
||||
if (!token) return
|
||||
setGithubTokenSaving(true)
|
||||
setGithubTokenError("")
|
||||
try {
|
||||
await fetchApi<{ success: boolean; configured: boolean }>("/api/apps/github-token", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
setGithubTokenConfigured(true)
|
||||
setGithubTokenDraft("")
|
||||
setGithubTokenEditMode(false)
|
||||
setGithubTokenSaved(true)
|
||||
window.setTimeout(() => setGithubTokenSaved(false), 2500)
|
||||
} catch (err) {
|
||||
console.error("Failed to save GitHub API token:", err)
|
||||
setGithubTokenError(t("settings.githubApi.saveFailed"))
|
||||
} finally {
|
||||
setGithubTokenSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeGithubToken = async () => {
|
||||
setGithubTokenSaving(true)
|
||||
setGithubTokenError("")
|
||||
try {
|
||||
await fetchApi<{ success: boolean; configured: boolean }>("/api/apps/github-token", {
|
||||
method: "DELETE",
|
||||
})
|
||||
setGithubTokenConfigured(false)
|
||||
setGithubTokenDraft("")
|
||||
setGithubTokenEditMode(false)
|
||||
setGithubTokenSaved(true)
|
||||
window.setTimeout(() => setGithubTokenSaved(false), 2500)
|
||||
} catch (err) {
|
||||
console.error("Failed to remove GitHub API token:", err)
|
||||
setGithubTokenError(t("settings.githubApi.removeFailed"))
|
||||
} finally {
|
||||
setGithubTokenSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProxmenuxTools()
|
||||
getUnitsSettings()
|
||||
@@ -459,6 +527,7 @@ export function Settings() {
|
||||
loadActiveSuppressions()
|
||||
loadNetworkInterfaces()
|
||||
loadSnippetsStorage()
|
||||
loadGithubTokenStatus()
|
||||
}, [])
|
||||
|
||||
// Refresh the Active Suppressions list whenever:
|
||||
@@ -1803,6 +1872,113 @@ export function Settings() {
|
||||
is re-enabled). */}
|
||||
<LxcUpdateDetection />
|
||||
|
||||
{/* GitHub API — optional authentication for app upstream checks. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5 text-foreground" />
|
||||
<CardTitle>{t("settings.githubApi.title")}</CardTitle>
|
||||
</div>
|
||||
{!githubTokenLoading && (
|
||||
<div className="flex items-center gap-2">
|
||||
{githubTokenSaved && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{githubTokenEditMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={() => {
|
||||
setGithubTokenDraft("")
|
||||
setGithubTokenError("")
|
||||
setGithubTokenEditMode(false)
|
||||
}}
|
||||
disabled={githubTokenSaving}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={saveGithubToken}
|
||||
disabled={githubTokenSaving || !githubTokenDraft.trim()}
|
||||
>
|
||||
{githubTokenSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => {
|
||||
setGithubTokenError("")
|
||||
setGithubTokenEditMode(true)
|
||||
}}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{githubTokenConfigured ? t("actions.edit") : t("settings.githubApi.configure")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>{t("settings.githubApi.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className={githubTokenEditMode ? "bg-accent [&_input]:bg-background" : undefined}>
|
||||
{githubTokenLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : githubTokenEditMode ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="github-api-token" className="text-sm font-medium text-foreground">
|
||||
{t("settings.githubApi.tokenLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="github-api-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={githubTokenDraft}
|
||||
onChange={(event) => setGithubTokenDraft(event.target.value)}
|
||||
placeholder={githubTokenConfigured ? "••••••••••••" : t("settings.githubApi.tokenPlaceholder")}
|
||||
disabled={githubTokenSaving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.githubApi.tokenHelp")}</p>
|
||||
</div>
|
||||
{githubTokenConfigured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/40 text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={removeGithubToken}
|
||||
disabled={githubTokenSaving}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
{t("settings.githubApi.removeToken")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={`h-2 w-2 rounded-full ${githubTokenConfigured ? "bg-green-500" : "bg-muted-foreground/60"}`} />
|
||||
<span className={githubTokenConfigured ? "text-green-500" : "text-muted-foreground"}>
|
||||
{githubTokenConfigured ? t("settings.githubApi.configured") : t("settings.githubApi.notConfigured")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{githubTokenError && (
|
||||
<div className="mt-3 flex items-start gap-2 text-sm text-red-400">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{githubTokenError}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<NotificationSettings />
|
||||
|
||||
|
||||
@@ -1087,6 +1087,9 @@ export function VirtualMachines() {
|
||||
cache.firewall.delete(vm.vmid)
|
||||
dockerInventoryRequestedRef.current.delete(vm.vmid)
|
||||
invalidateLxcApps(vm.vmid)
|
||||
if (selectedVMRef.current?.vmid === vm.vmid) {
|
||||
setScheduleLoaded(null)
|
||||
}
|
||||
setVmConfigs((existing) => {
|
||||
if (!(vm.vmid in existing)) return existing
|
||||
const next = { ...existing }
|
||||
@@ -1830,6 +1833,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [scheduleLastRunAt, setScheduleLastRunAt] = useState<string | null>(null)
|
||||
const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState<string | null>(null)
|
||||
const [scheduleLastRunReason, setScheduleLastRunReason] = useState<string | null>(null)
|
||||
const [scheduleLastRunLog, setScheduleLastRunLog] = useState<string | null>(null)
|
||||
const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false)
|
||||
const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([])
|
||||
const [scheduleLogOpen, setScheduleLogOpen] = useState(false)
|
||||
const [scheduleLogLoading, setScheduleLogLoading] = useState(false)
|
||||
const [scheduleLogContent, setScheduleLogContent] = useState("")
|
||||
const [scheduleLogError, setScheduleLogError] = useState<string | null>(null)
|
||||
const [scheduleLogTruncated, setScheduleLogTruncated] = useState(false)
|
||||
const [scheduleSaving, setScheduleSaving] = useState(false)
|
||||
const [scheduleError, setScheduleError] = useState<string | null>(null)
|
||||
const [externalCron, setExternalCron] = useState<{
|
||||
@@ -1897,6 +1908,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setScheduleLastRunAt(s.last_run_at || null)
|
||||
setScheduleLastRunStatus(s.last_run_status || null)
|
||||
setScheduleLastRunReason(s.last_run_reason || null)
|
||||
setScheduleLastRunLog(s.last_run_log || null)
|
||||
setScheduleLastRunRebootRequired(s.last_run_reboot_required === true)
|
||||
setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages)
|
||||
? s.last_run_reboot_packages.map((value: any) => String(value))
|
||||
: [])
|
||||
setExternalCron(s.external_cron || null)
|
||||
}
|
||||
|
||||
@@ -1943,6 +1959,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openScheduleLog = async (vmid: number) => {
|
||||
setScheduleLogOpen(true)
|
||||
setScheduleLogLoading(true)
|
||||
setScheduleLogContent("")
|
||||
setScheduleLogError(null)
|
||||
setScheduleLogTruncated(false)
|
||||
try {
|
||||
const payload: any = await fetchApi(`/api/vms/${vmid}/schedule/log`)
|
||||
setScheduleLogContent(String(payload?.content || ""))
|
||||
setScheduleLogTruncated(payload?.truncated === true)
|
||||
} catch (e: any) {
|
||||
setScheduleLogError(e?.message || t("vmLxc.scheduled.logFailed"))
|
||||
} finally {
|
||||
setScheduleLogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveBulkUpdate = async (vmid: number) => {
|
||||
setBulkSaving(true)
|
||||
setBulkError(null)
|
||||
@@ -2094,6 +2127,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setScheduleLastRunAt(null)
|
||||
setScheduleLastRunStatus(null)
|
||||
setScheduleLastRunReason(null)
|
||||
setScheduleLastRunLog(null)
|
||||
setScheduleLastRunRebootRequired(false)
|
||||
setScheduleLastRunRebootPackages([])
|
||||
setScheduleReleaseDelayDays(0)
|
||||
} catch (e: any) {
|
||||
setScheduleError(e?.message || "Delete failed")
|
||||
@@ -2127,6 +2163,59 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return expr
|
||||
}
|
||||
|
||||
const renderScheduleRunDetails = (indented = false) => {
|
||||
if (!scheduleLastRunAt || !selectedVM) return null
|
||||
const spacing = indented ? "pl-4" : ""
|
||||
return (
|
||||
<div className={`space-y-1.5 ${spacing}`}>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vmLxc.scheduled.lastRun", { date: new Date(scheduleLastRunAt).toLocaleString() })}
|
||||
{scheduleLastRunStatus && (
|
||||
<> · <span className={scheduleLastRunStatus === "success" ? "text-green-400" : scheduleLastRunStatus === "partial" || scheduleLastRunStatus === "deferred" || scheduleLastRunStatus === "skipped" ? "text-amber-400" : "text-red-400"}>
|
||||
{scheduleLastRunStatus === "success"
|
||||
? t("vmLxc.scheduled.runSuccess")
|
||||
: scheduleLastRunStatus === "partial"
|
||||
? t("vmLxc.scheduled.runPartial")
|
||||
: scheduleLastRunStatus === "deferred"
|
||||
? t("vmLxc.scheduled.runDeferred")
|
||||
: scheduleLastRunStatus === "skipped"
|
||||
? t("vmLxc.scheduled.runSkipped")
|
||||
: t("vmLxc.scheduled.runFailed")}
|
||||
</span></>
|
||||
)}
|
||||
</div>
|
||||
{scheduleLastRunReason && (
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
{scheduleLastRunReason}
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunRebootRequired && (
|
||||
<div className="text-xs text-amber-400 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div>{t("vmLxc.scheduled.rebootRequired")}</div>
|
||||
{scheduleLastRunRebootPackages.length > 0 && (
|
||||
<div className="text-muted-foreground mt-0.5 break-words">
|
||||
{t("vmLxc.scheduled.rebootPackages", { packages: scheduleLastRunRebootPackages.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunLog && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openScheduleLog(selectedVM.vmid)}
|
||||
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{t("vmLxc.scheduled.viewLog")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Load the schedule once whenever the user opens the Updates tab
|
||||
// of a specific LXC. Keying on vmid keeps us from re-fetching on
|
||||
// every render but also refetches after switching CTs.
|
||||
@@ -2134,9 +2223,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
if (activeModalTab !== "updates") return
|
||||
if (!selectedVM || selectedVM.type !== "lxc") return
|
||||
if (scheduleLoaded !== selectedVM.vmid) loadSchedule(selectedVM.vmid)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeModalTab, selectedVM?.vmid, selectedVM?.modal_cache_revision, scheduleLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeModalTab !== "updates") return
|
||||
if (!selectedVM || selectedVM.type !== "lxc") return
|
||||
if (bulkLoaded !== selectedVM.vmid) loadBulkUpdate(selectedVM.vmid)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeModalTab, selectedVM?.vmid])
|
||||
}, [activeModalTab, selectedVM?.vmid, bulkLoaded])
|
||||
|
||||
// Docker drift is opt-in: read it only after Docker has been registered and
|
||||
// only when the user opens Updates. This request deliberately DOES NOT use
|
||||
@@ -6198,29 +6293,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<> · {t("vmLxc.scheduled.releaseDelaySummary", { days: scheduleReleaseDelayDays })}</>
|
||||
)}
|
||||
</div>
|
||||
{scheduleLastRunAt && (
|
||||
<div className="text-xs text-muted-foreground pl-4">
|
||||
{t("vmLxc.scheduled.lastRun", { date: new Date(scheduleLastRunAt).toLocaleString() })}
|
||||
{scheduleLastRunStatus && (
|
||||
<> · <span className={scheduleLastRunStatus === "success" ? "text-green-400" : scheduleLastRunStatus === "partial" || scheduleLastRunStatus === "deferred" || scheduleLastRunStatus === "skipped" ? "text-amber-400" : "text-red-400"}>
|
||||
{scheduleLastRunStatus === "success"
|
||||
? t("vmLxc.scheduled.runSuccess")
|
||||
: scheduleLastRunStatus === "partial"
|
||||
? t("vmLxc.scheduled.runPartial")
|
||||
: scheduleLastRunStatus === "deferred"
|
||||
? t("vmLxc.scheduled.runDeferred")
|
||||
: scheduleLastRunStatus === "skipped"
|
||||
? t("vmLxc.scheduled.runSkipped")
|
||||
: t("vmLxc.scheduled.runFailed")}
|
||||
</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunReason && (
|
||||
<div className="text-xs text-muted-foreground pl-4 break-words">
|
||||
{scheduleLastRunReason}
|
||||
</div>
|
||||
)}
|
||||
{renderScheduleRunDetails(true)}
|
||||
</div>
|
||||
)}
|
||||
{!optionsEditMode && !scheduleConfigured && !externalCron && (
|
||||
@@ -6351,25 +6424,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunAt && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vmLxc.scheduled.lastRun", { date: new Date(scheduleLastRunAt).toLocaleString() })}
|
||||
{scheduleLastRunStatus && (
|
||||
<> · <span className={scheduleLastRunStatus === "success" ? "text-green-400" : scheduleLastRunStatus === "partial" || scheduleLastRunStatus === "deferred" || scheduleLastRunStatus === "skipped" ? "text-amber-400" : "text-red-400"}>
|
||||
{scheduleLastRunStatus === "success"
|
||||
? t("vmLxc.scheduled.runSuccess")
|
||||
: scheduleLastRunStatus === "partial"
|
||||
? t("vmLxc.scheduled.runPartial")
|
||||
: scheduleLastRunStatus === "deferred"
|
||||
? t("vmLxc.scheduled.runDeferred")
|
||||
: scheduleLastRunStatus === "skipped"
|
||||
? t("vmLxc.scheduled.runSkipped")
|
||||
: t("vmLxc.scheduled.runFailed")}
|
||||
</span></>
|
||||
)}
|
||||
{scheduleLastRunReason && <div className="mt-1 break-words">{scheduleLastRunReason}</div>}
|
||||
</div>
|
||||
)}
|
||||
{renderScheduleRunDetails()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6996,6 +7051,51 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={scheduleLogOpen} onOpenChange={setScheduleLogOpen}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
{t("vmLxc.scheduled.logTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("vmLxc.scheduled.logDescription", {
|
||||
name: selectedVM?.name || "LXC",
|
||||
vmid: selectedVM?.vmid || "—",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1">
|
||||
{scheduleLogLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
{t("vmLxc.scheduled.logLoading")}
|
||||
</div>
|
||||
) : scheduleLogError ? (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
|
||||
{scheduleLogError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{scheduleLogTruncated && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-xs text-amber-400">
|
||||
{t("vmLxc.scheduled.logTruncated")}
|
||||
</div>
|
||||
)}
|
||||
<pre className="max-h-[58vh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-4 text-xs font-mono text-foreground">
|
||||
{scheduleLogContent || t("vmLxc.scheduled.logEmpty")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setScheduleLogOpen(false)}>
|
||||
{t("vmLxc.scheduled.closeLog")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* LXC Terminal Modal */}
|
||||
{terminalVmid !== null && (
|
||||
<LxcTerminalModal
|
||||
|
||||
@@ -8,4 +8,4 @@
|
||||
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
|
||||
//
|
||||
// Keep the three in sync on every bump.
|
||||
export const APP_VERSION = "1.2.5"
|
||||
export const APP_VERSION = "1.2.6"
|
||||
|
||||
@@ -1164,6 +1164,16 @@
|
||||
"targetApp": "Anwendung",
|
||||
"targetBoth": "Betriebssystem + Anwendung",
|
||||
"lastRun": "Letzte Ausführung: {date}",
|
||||
"rebootRequired": "Zum Abschluss der Aktualisierung ist ein Neustart erforderlich.",
|
||||
"rebootPackages": "Pakete: {packages}",
|
||||
"viewLog": "Protokoll anzeigen",
|
||||
"logTitle": "Aktualisierungsprotokoll",
|
||||
"logDescription": "Ausgabe der letzten geplanten Aktualisierung von {name} (LXC {vmid}).",
|
||||
"logLoading": "Protokoll wird geladen…",
|
||||
"logEmpty": "Dieser Lauf hat keine Ausgabe erzeugt.",
|
||||
"logFailed": "Das Aktualisierungsprotokoll konnte nicht geladen werden.",
|
||||
"logTruncated": "Es wird nur das Ende des Protokolls angezeigt, da es das Anzeigelimit überschreitet.",
|
||||
"closeLog": "Schließen",
|
||||
"runSuccess": "✓ Erfolg",
|
||||
"runPartial": "teilweise abgeschlossen",
|
||||
"runFailed": "✗ fehlgeschlagen",
|
||||
@@ -1527,6 +1537,7 @@
|
||||
"editButton": "Bearbeiten",
|
||||
"upstreamErrorTimeout": "Netzwerk-Timeout beim Kontaktieren des Upstreams",
|
||||
"upstreamErrorNetwork": "Netzwerkfehler: {detail}",
|
||||
"upstreamErrorGithubRateLimit": "Das GitHub-Anfragelimit wurde erreicht. Konfigurieren Sie unter Einstellungen → GitHub API ein optionales Token oder versuchen Sie es später erneut.",
|
||||
"upstreamErrorGeneric": "Upstream-Prüfung fehlgeschlagen: {detail}",
|
||||
"notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN – zum Stummschalten klicken",
|
||||
"notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet – zum Aktivieren klicken",
|
||||
@@ -1648,6 +1659,20 @@
|
||||
"saveFailed": "Die Einstellung für die LXC-Update-Erkennung konnte nicht gespeichert werden.",
|
||||
"purgedMessage": "{count} LXC-Einträge aus der Registrierung entfernt. Durch erneutes Aktivieren der Erkennung werden sie beim nächsten Scan-Zyklus neu aufgefüllt."
|
||||
},
|
||||
"githubApi": {
|
||||
"title": "GitHub API",
|
||||
"description": "Optionale Authentifizierung für die Release- und Tag-Prüfungen registrierter Anwendungen.",
|
||||
"configure": "Konfigurieren",
|
||||
"tokenLabel": "Persönliches Zugriffstoken",
|
||||
"tokenPlaceholder": "github_pat_...",
|
||||
"tokenHelp": "Das Token wird verschlüsselt gespeichert. ProxMenux verwendet es ausschließlich für schreibgeschützte GitHub-API-Anfragen und zeigt es danach nicht erneut an.",
|
||||
"configured": "Token konfiguriert",
|
||||
"notConfigured": "Anonymes GitHub-Anfragekontingent wird verwendet",
|
||||
"removeToken": "Token entfernen",
|
||||
"loadFailed": "Die GitHub-API-Einstellung konnte nicht geladen werden.",
|
||||
"saveFailed": "Das GitHub-API-Token konnte nicht gespeichert werden.",
|
||||
"removeFailed": "Das GitHub-API-Token konnte nicht entfernt werden."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Konfigurieren Sie Benachrichtigungskanäle und Ereignisfilter. Erhalten Sie Benachrichtigungen per Telegram, Gotify, Discord oder E-Mail.",
|
||||
@@ -1922,7 +1947,8 @@
|
||||
"gemini": "Es ist eine kostenlose Stufe mit einem guten Preis-Leistungs-Verhältnis verfügbar.",
|
||||
"ollama": "Verwendet Modelle auf Ihrem Ollama-Server. Völlig lokal, privat und kostenlos nutzbar.",
|
||||
"openrouter": "Zugriff auf mehr als 100 Modelle über einen API-Schlüssel."
|
||||
}
|
||||
},
|
||||
"loadModelsFailed": "Modelle konnten nicht geladen werden — prüfe den API-Schlüssel oder die Endpunkt-URL."
|
||||
},
|
||||
"telegramGuide": {
|
||||
"title": "Anleitung zur Einrichtung des Telegram-Bots",
|
||||
@@ -2087,7 +2113,7 @@
|
||||
"backupCodeHint": "Sie können auch einen Backup-Code verwenden (Format: XXXX-XXXX)",
|
||||
"backToLogin": "Zurück zum Login",
|
||||
"verifyCode": "Code überprüfen",
|
||||
"version": "ProxMenux Monitor v1.2.5"
|
||||
"version": "ProxMenux Monitor v1.2.6"
|
||||
},
|
||||
"account": {
|
||||
"signedIn": "Angemeldet",
|
||||
@@ -3143,7 +3169,13 @@
|
||||
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
|
||||
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
|
||||
"multilingual": "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
|
||||
"aiCustomEndpoint": "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
"secureGatewayArch": "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
"atomicNotifications": "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
"borgSshPort": "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
"githubToken": "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
"replicationContext": "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.)."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3962,7 +3994,8 @@
|
||||
"vmsLxcs": "VMs/LXCs",
|
||||
"when": "Wann",
|
||||
"whenLabel": "Wann",
|
||||
"zfsPools": "ZFS-Pools"
|
||||
"zfsPools": "ZFS-Pools",
|
||||
"sshPort": "SSH-Port"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "Backups.",
|
||||
|
||||
@@ -1163,6 +1163,16 @@
|
||||
"targetApp": "Application",
|
||||
"targetBoth": "OS + application",
|
||||
"lastRun": "Last run: {date}",
|
||||
"rebootRequired": "A restart is required to complete the update.",
|
||||
"rebootPackages": "Packages: {packages}",
|
||||
"viewLog": "View log",
|
||||
"logTitle": "Update log",
|
||||
"logDescription": "Output from the latest scheduled update of {name} (LXC {vmid}).",
|
||||
"logLoading": "Loading log…",
|
||||
"logEmpty": "This run produced no output.",
|
||||
"logFailed": "The update log could not be loaded.",
|
||||
"logTruncated": "Only the end of the log is shown because it exceeds the display limit.",
|
||||
"closeLog": "Close",
|
||||
"runSuccess": "✓ success",
|
||||
"runPartial": "completed partially",
|
||||
"runFailed": "✗ failed",
|
||||
@@ -1432,6 +1442,7 @@
|
||||
"updateAvailableBadge": "Update available",
|
||||
"upstreamErrorTimeout": "Network timeout while contacting upstream",
|
||||
"upstreamErrorNetwork": "Network error: {detail}",
|
||||
"upstreamErrorGithubRateLimit": "GitHub's request limit has been reached. Configure an optional token in Settings → GitHub API, or try again later.",
|
||||
"upstreamErrorGeneric": "Upstream check failed: {detail}",
|
||||
"portDescriptionPlaceholder": "Description (e.g. Web UI, go2rtc, admin)",
|
||||
"portPortPlaceholder": "port",
|
||||
@@ -1647,6 +1658,20 @@
|
||||
"saveFailed": "Could not save the LXC update detection setting.",
|
||||
"purgedMessage": "{count} LXC entries removed from the registry. Re-enabling detection will repopulate them on the next scan cycle."
|
||||
},
|
||||
"githubApi": {
|
||||
"title": "GitHub API",
|
||||
"description": "Optional authentication for the release and tag checks used by registered applications.",
|
||||
"configure": "Configure",
|
||||
"tokenLabel": "Personal access token",
|
||||
"tokenPlaceholder": "github_pat_...",
|
||||
"tokenHelp": "The token is stored encrypted. ProxMenux uses it only for read-only GitHub API requests and never displays it again.",
|
||||
"configured": "Token configured",
|
||||
"notConfigured": "Using GitHub's anonymous request quota",
|
||||
"removeToken": "Remove token",
|
||||
"loadFailed": "Could not load the GitHub API setting.",
|
||||
"saveFailed": "Could not save the GitHub API token.",
|
||||
"removeFailed": "Could not remove the GitHub API token."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email.",
|
||||
@@ -1921,7 +1946,8 @@
|
||||
"gemini": "A free tier is available, with a good quality-to-price ratio.",
|
||||
"ollama": "Uses models on your Ollama server. Fully local, private and free to run.",
|
||||
"openrouter": "Access to more than 100 models through one API key."
|
||||
}
|
||||
},
|
||||
"loadModelsFailed": "Failed to load models — check the API key or endpoint URL."
|
||||
},
|
||||
"telegramGuide": {
|
||||
"title": "Telegram bot setup guide",
|
||||
@@ -2086,7 +2112,7 @@
|
||||
"backupCodeHint": "You can also use a backup code (format: XXXX-XXXX)",
|
||||
"backToLogin": "Back to login",
|
||||
"verifyCode": "Verify Code",
|
||||
"version": "ProxMenux Monitor v1.2.5"
|
||||
"version": "ProxMenux Monitor v1.2.6"
|
||||
},
|
||||
"account": {
|
||||
"signedIn": "Signed in",
|
||||
@@ -3142,7 +3168,13 @@
|
||||
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
|
||||
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
|
||||
"multilingual": "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
|
||||
"aiCustomEndpoint": "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
"secureGatewayArch": "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
"atomicNotifications": "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
"borgSshPort": "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
"githubToken": "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
"replicationContext": "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.)."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3962,7 +3994,8 @@
|
||||
"vmsLxcs": "VMs/LXCs",
|
||||
"when": "When",
|
||||
"whenLabel": "When",
|
||||
"zfsPools": "ZFS pools"
|
||||
"zfsPools": "ZFS pools",
|
||||
"sshPort": "SSH port"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "backups.",
|
||||
|
||||
@@ -1164,6 +1164,16 @@
|
||||
"targetApp": "Solicitud",
|
||||
"targetBoth": "SO + aplicación",
|
||||
"lastRun": "Última ejecución: {date}",
|
||||
"rebootRequired": "Es necesario reiniciar para completar la actualización.",
|
||||
"rebootPackages": "Paquetes: {packages}",
|
||||
"viewLog": "Ver log",
|
||||
"logTitle": "Log de actualización",
|
||||
"logDescription": "Salida de la última actualización programada de {name} (LXC {vmid}).",
|
||||
"logLoading": "Cargando log…",
|
||||
"logEmpty": "Esta ejecución no produjo ninguna salida.",
|
||||
"logFailed": "No se pudo cargar el log de actualización.",
|
||||
"logTruncated": "Solo se muestra el final del log porque supera el límite de visualización.",
|
||||
"closeLog": "Cerrar",
|
||||
"runSuccess": "✓ éxito",
|
||||
"runPartial": "completada parcialmente",
|
||||
"runFailed": "✗ falló",
|
||||
@@ -1432,6 +1442,7 @@
|
||||
"upToDateBadge": "Actualizado",
|
||||
"upstreamErrorTimeout": "Tiempo de espera agotado al contactar con el origen",
|
||||
"upstreamErrorNetwork": "Error de red: {detail}",
|
||||
"upstreamErrorGithubRateLimit": "Se ha alcanzado el límite de solicitudes de GitHub. Configure un token opcional en Ajustes → API de GitHub o vuelva a intentarlo más tarde.",
|
||||
"upstreamErrorGeneric": "Fallo al comprobar el origen: {detail}",
|
||||
"updateAvailableBadge": "Actualización disponible",
|
||||
"portDescriptionPlaceholder": "Descripción (por ejemplo, interfaz de usuario web, go2rtc, administrador)",
|
||||
@@ -1648,6 +1659,20 @@
|
||||
"saveFailed": "No se pudo guardar la configuración de detección de actualizaciones de LXC.",
|
||||
"purgedMessage": "{count} Entradas LXC eliminadas del registro. Al volver a habilitar la detección, se volverán a llenar en el siguiente ciclo de escaneo."
|
||||
},
|
||||
"githubApi": {
|
||||
"title": "API de GitHub",
|
||||
"description": "Autenticación opcional para comprobar los lanzamientos y las etiquetas de las aplicaciones registradas.",
|
||||
"configure": "Configurar",
|
||||
"tokenLabel": "Token de acceso personal",
|
||||
"tokenPlaceholder": "github_pat_...",
|
||||
"tokenHelp": "El token se guarda cifrado. ProxMenux solo lo utiliza para consultas de solo lectura a la API de GitHub y no vuelve a mostrarlo.",
|
||||
"configured": "Token configurado",
|
||||
"notConfigured": "Usando la cuota de solicitudes anónimas de GitHub",
|
||||
"removeToken": "Eliminar token",
|
||||
"loadFailed": "No se pudo cargar el ajuste de la API de GitHub.",
|
||||
"saveFailed": "No se pudo guardar el token de la API de GitHub.",
|
||||
"removeFailed": "No se pudo eliminar el token de la API de GitHub."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"description": "Configure canales de notificación y filtros de eventos. Recibe alertas vía Telegram, Gotify, Discord o Email.",
|
||||
@@ -1922,7 +1947,8 @@
|
||||
"gemini": "Hay disponible un nivel gratuito, con una buena relación calidad-precio.",
|
||||
"ollama": "Utiliza modelos en su servidor Ollama. Totalmente local, privado y gratuito.",
|
||||
"openrouter": "Acceso a más de 100 modelos a través de una clave API."
|
||||
}
|
||||
},
|
||||
"loadModelsFailed": "No se pudieron cargar los modelos — comprueba la API key o la URL del endpoint."
|
||||
},
|
||||
"telegramGuide": {
|
||||
"title": "Guía de configuración del bot de Telegram",
|
||||
@@ -2087,7 +2113,7 @@
|
||||
"backupCodeHint": "También puedes utilizar un código de respaldo (formato: XXXX-XXXX)",
|
||||
"backToLogin": "Volver a iniciar sesión",
|
||||
"verifyCode": "Verificar código",
|
||||
"version": "ProxMenux Monitor v1.2.5"
|
||||
"version": "ProxMenux Monitor v1.2.6"
|
||||
},
|
||||
"account": {
|
||||
"signedIn": "Iniciado sesión",
|
||||
@@ -3143,7 +3169,13 @@
|
||||
"appsDashboard": "Nueva pestaña Apps de nivel superior — un lanzador único para cada enlace web del nodo. Las aplicaciones registradas en LXC y los enlaces web personalizados comparten la misma cuadrícula con etiquetas de categoría, búsqueda y acceso directo al modal del invitado.",
|
||||
"lxcAppsUpdates": "La pestaña App dentro del modal de cada LXC registra las aplicaciones instaladas, captura sus enlaces web y realiza seguimiento de versiones. La pestaña Updates rediseñada aplica actualizaciones de paquetes del sistema y de aplicaciones desde un solo botón; Docker Engine y cada imagen siguen el mismo ciclo de 24 horas, con acción 'Comprobar ahora' bajo demanda.",
|
||||
"multilingual": "El Monitor ahora habla 8 idiomas: inglés, español, alemán, francés, italiano, portugués, sueco y eslovaco. Un enorme agradecimiento a @vaso73 por construir la base de i18n que lo hizo posible.",
|
||||
"nvidiaMultiGpu": "El ciclo de vida del driver NVIDIA pasa a propiedad por BDF exacto, de modo que un host multi-GPU puede pasar una tarjeta a una VM y mantener la otra operativa en el host o en LXCs, junto con un selector de versión sensible al kernel, la rama y la GPU (#298)."
|
||||
"nvidiaMultiGpu": "El ciclo de vida del driver NVIDIA pasa a propiedad por BDF exacto, de modo que un host multi-GPU puede pasar una tarjeta a una VM y mantener la otra operativa en el host o en LXCs, junto con un selector de versión sensible al kernel, la rama y la GPU (#298).",
|
||||
"aiCustomEndpoint": "Endpoint OpenAI personalizado del Asistente IA — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute y cualquier proxy autoalojado en IPs privadas, loopback o redes Docker se reconocen al cargar el catálogo de modelos. El desplegable muestra el error devuelto por el servidor (o el motivo de red subyacente) justo debajo del botón Cargar (#325, reportado por @jorgeffonte).",
|
||||
"secureGatewayArch": "Asistente Secure Gateway — la descarga de plantilla Alpine, la selección de plantilla local y pct create coinciden con la arquitectura real del host, de modo que los hosts x86_64 reciben contenedores amd64 y los hosts arm64 reciben contenedores arm64 (#324, reportado por @N0X4DD0).",
|
||||
"atomicNotifications": "Los eventos de notificación reservan su huella de deduplicación de forma atómica antes del procesado por IA y del envío por canal, así que colectores concurrentes, callbacks de finalización o procesos Monitor paralelos no pueden enviar el mismo evento dos veces. La reserva se libera cuando ningún canal tiene éxito, preservando los reintentos.",
|
||||
"borgSshPort": "Destino remoto Borg — el diálogo Añadir destino Borg y el TUI del shell aceptan un puerto SSH personalizado. BORG_RSH, el flujo de instalación automática de clave y la sonda de capacidad lo respetan. Totalmente retrocompatible con las entradas existentes creadas sin un puerto explícito (sugerido por @songochain en la discusión #236).",
|
||||
"githubToken": "Settings → GitHub API acepta un token de acceso personal opcional para las comprobaciones de releases y tags cuando se agota la cuota anónima. El token se guarda cifrado y nunca se devuelve al navegador; el error de rate limit está traducido en todos los idiomas del Monitor (sugerido por @SystemIdleProcess en la discusión #306).",
|
||||
"replicationContext": "Las notificaciones nativas de fallo de replicación de Proxmox ahora resuelven el ID del trabajo de replicación, el ID de la VM/LXC afectada y el nombre del guest; el bloque de error exacto de Proxmox se conserva como motivo, y cada trabajo de replicación deduplica de forma independiente (reportado por Ale R.)."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3962,7 +3994,8 @@
|
||||
"vmsLxcs": "VM/LXC",
|
||||
"when": "Cuando",
|
||||
"whenLabel": "Cuando",
|
||||
"zfsPools": "grupos ZFS"
|
||||
"zfsPools": "grupos ZFS",
|
||||
"sshPort": "Puerto SSH"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "copias de seguridad.",
|
||||
|
||||
@@ -1164,6 +1164,16 @@
|
||||
"targetApp": "Application",
|
||||
"targetBoth": "Système d'exploitation + application",
|
||||
"lastRun": "Dernière exécution : {date}",
|
||||
"rebootRequired": "Un redémarrage est nécessaire pour terminer la mise à jour.",
|
||||
"rebootPackages": "Paquets : {packages}",
|
||||
"viewLog": "Voir le journal",
|
||||
"logTitle": "Journal de mise à jour",
|
||||
"logDescription": "Sortie de la dernière mise à jour planifiée de {name} (LXC {vmid}).",
|
||||
"logLoading": "Chargement du journal…",
|
||||
"logEmpty": "Cette exécution n’a produit aucune sortie.",
|
||||
"logFailed": "Impossible de charger le journal de mise à jour.",
|
||||
"logTruncated": "Seule la fin du journal est affichée, car il dépasse la limite d’affichage.",
|
||||
"closeLog": "Fermer",
|
||||
"runSuccess": "✓ succès",
|
||||
"runPartial": "partiellement terminée",
|
||||
"runFailed": "✗ échoué",
|
||||
@@ -1527,6 +1537,7 @@
|
||||
"editButton": "Modifier",
|
||||
"upstreamErrorTimeout": "expiration du délai d'attente du réseau lors du contact en amont",
|
||||
"upstreamErrorNetwork": "Erreur réseau : {detail}",
|
||||
"upstreamErrorGithubRateLimit": "La limite de requêtes GitHub a été atteinte. Configurez un jeton facultatif dans Paramètres → API GitHub ou réessayez plus tard.",
|
||||
"upstreamErrorGeneric": "Échec de la vérification en amont : {detail}",
|
||||
"notificationsEnabled": "Notifications de mise à jour en amont activées – cliquez pour désactiver le son",
|
||||
"notificationsMuted": "Notifications de mise à jour en amont MUTED – cliquez pour activer",
|
||||
@@ -1648,6 +1659,20 @@
|
||||
"saveFailed": "Impossible d'enregistrer le paramètre de détection de mise à jour LXC.",
|
||||
"purgedMessage": "{count} Entrées LXC supprimées du registre. La réactivation de la détection les repeuplera lors du prochain cycle d'analyse."
|
||||
},
|
||||
"githubApi": {
|
||||
"title": "API GitHub",
|
||||
"description": "Authentification facultative pour vérifier les versions et les étiquettes des applications enregistrées.",
|
||||
"configure": "Configurer",
|
||||
"tokenLabel": "Jeton d'accès personnel",
|
||||
"tokenPlaceholder": "github_pat_...",
|
||||
"tokenHelp": "Le jeton est stocké sous forme chiffrée. ProxMenux l'utilise uniquement pour des requêtes en lecture seule vers l'API GitHub et ne l'affiche plus ensuite.",
|
||||
"configured": "Jeton configuré",
|
||||
"notConfigured": "Utilisation du quota de requêtes anonymes de GitHub",
|
||||
"removeToken": "Supprimer le jeton",
|
||||
"loadFailed": "Impossible de charger le paramètre de l'API GitHub.",
|
||||
"saveFailed": "Impossible d'enregistrer le jeton de l'API GitHub.",
|
||||
"removeFailed": "Impossible de supprimer le jeton de l'API GitHub."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Configurez les canaux de notification et les filtres d'événements. Recevez des alertes via Telegram, Gotify, Discord ou Email.",
|
||||
@@ -1922,7 +1947,8 @@
|
||||
"gemini": "Un niveau gratuit est disponible, avec un bon rapport qualité-prix.",
|
||||
"ollama": "Utilise des modèles sur votre serveur Ollama. Entièrement local, privé et gratuit.",
|
||||
"openrouter": "Accès à plus de 100 modèles via une seule clé API."
|
||||
}
|
||||
},
|
||||
"loadModelsFailed": "Impossible de charger les modèles — vérifie la clé API ou l'URL du endpoint."
|
||||
},
|
||||
"telegramGuide": {
|
||||
"title": "Guide de configuration du robot Telegram",
|
||||
@@ -2087,7 +2113,7 @@
|
||||
"backupCodeHint": "Vous pouvez également utiliser un code de secours (format : XXXX-XXXX)",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"verifyCode": "Vérifier le code",
|
||||
"version": "ProxMenux Monitor v1.2.5"
|
||||
"version": "ProxMenux Monitor v1.2.6"
|
||||
},
|
||||
"account": {
|
||||
"signedIn": "Connecté",
|
||||
@@ -3143,7 +3169,13 @@
|
||||
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
|
||||
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
|
||||
"multilingual": "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
|
||||
"aiCustomEndpoint": "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
"secureGatewayArch": "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
"atomicNotifications": "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
"borgSshPort": "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
"githubToken": "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
"replicationContext": "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.)."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3962,7 +3994,8 @@
|
||||
"vmsLxcs": "VM/LXC",
|
||||
"when": "Quand",
|
||||
"whenLabel": "Quand",
|
||||
"zfsPools": "Pools ZFS"
|
||||
"zfsPools": "Pools ZFS",
|
||||
"sshPort": "Port SSH"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "sauvegardes.",
|
||||
|
||||
@@ -1164,6 +1164,16 @@
|
||||
"targetApp": "Applicazione",
|
||||
"targetBoth": "Sistema operativo + applicazione",
|
||||
"lastRun": "Ultima esecuzione: {date}",
|
||||
"rebootRequired": "È necessario un riavvio per completare l’aggiornamento.",
|
||||
"rebootPackages": "Pacchetti: {packages}",
|
||||
"viewLog": "Visualizza log",
|
||||
"logTitle": "Log di aggiornamento",
|
||||
"logDescription": "Output dell’ultimo aggiornamento pianificato di {name} (LXC {vmid}).",
|
||||
"logLoading": "Caricamento del log…",
|
||||
"logEmpty": "Questa esecuzione non ha prodotto alcun output.",
|
||||
"logFailed": "Impossibile caricare il log di aggiornamento.",
|
||||
"logTruncated": "Viene mostrata solo la parte finale del log perché supera il limite di visualizzazione.",
|
||||
"closeLog": "Chiudi",
|
||||
"runSuccess": "✓ successo",
|
||||
"runPartial": "completato parzialmente",
|
||||
"runFailed": "✗ fallito",
|
||||
@@ -1527,6 +1537,7 @@
|
||||
"editButton": "Modificare",
|
||||
"upstreamErrorTimeout": "timeout della rete durante il contatto a monte",
|
||||
"upstreamErrorNetwork": "errore di rete: {detail}",
|
||||
"upstreamErrorGithubRateLimit": "È stato raggiunto il limite di richieste GitHub. Configura un token facoltativo in Impostazioni → API GitHub oppure riprova più tardi.",
|
||||
"upstreamErrorGeneric": "controllo upstream non riuscito: {detail}",
|
||||
"notificationsEnabled": "notifiche di aggiornamento upstream attivate: fai clic per disattivare l'audio",
|
||||
"notificationsMuted": "notifiche di aggiornamento upstream MUTED: fare clic per abilitare",
|
||||
@@ -1648,6 +1659,20 @@
|
||||
"saveFailed": "Impossibile salvare l'impostazione di rilevamento degli aggiornamenti LXC.",
|
||||
"purgedMessage": "{count} Voci LXC rimosse dal registro. La riattivazione del rilevamento li ripopolarà al ciclo di scansione successivo."
|
||||
},
|
||||
"githubApi": {
|
||||
"title": "API GitHub",
|
||||
"description": "Autenticazione facoltativa per controllare release e tag delle applicazioni registrate.",
|
||||
"configure": "Configura",
|
||||
"tokenLabel": "Token di accesso personale",
|
||||
"tokenPlaceholder": "github_pat_...",
|
||||
"tokenHelp": "Il token viene archiviato in forma cifrata. ProxMenux lo utilizza solo per richieste di sola lettura all'API GitHub e non lo mostra più dopo il salvataggio.",
|
||||
"configured": "Token configurato",
|
||||
"notConfigured": "Utilizzo della quota di richieste anonime di GitHub",
|
||||
"removeToken": "Rimuovi token",
|
||||
"loadFailed": "Impossibile caricare l'impostazione dell'API GitHub.",
|
||||
"saveFailed": "Impossibile salvare il token dell'API GitHub.",
|
||||
"removeFailed": "Impossibile rimuovere il token dell'API GitHub."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"description": "Configura canali di notifica e filtri eventi. Ricevi avvisi tramite Telegram, Gotify, Discord o e-mail.",
|
||||
@@ -1922,7 +1947,8 @@
|
||||
"gemini": "È disponibile un livello gratuito, con un buon rapporto qualità-prezzo.",
|
||||
"ollama": "Utilizza i modelli sul tuo server Ollama. Completamente locale, privato e gratuito.",
|
||||
"openrouter": "Accesso a più di 100 modelli tramite una chiave API."
|
||||
}
|
||||
},
|
||||
"loadModelsFailed": "Impossibile caricare i modelli — controlla la API key o l'URL dell'endpoint."
|
||||
},
|
||||
"telegramGuide": {
|
||||
"title": "Guida alla configurazione del bot di Telegram",
|
||||
@@ -2087,7 +2113,7 @@
|
||||
"backupCodeHint": "Puoi anche utilizzare un codice di backup (formato: XXXX-XXXX)",
|
||||
"backToLogin": "Torna al login",
|
||||
"verifyCode": "Verifica codice",
|
||||
"version": "ProxMenux Monitor v1.2.5"
|
||||
"version": "ProxMenux Monitor v1.2.6"
|
||||
},
|
||||
"account": {
|
||||
"signedIn": "Effettuato l'accesso",
|
||||
@@ -3143,7 +3169,13 @@
|
||||
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
|
||||
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
|
||||
"multilingual": "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
|
||||
"aiCustomEndpoint": "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
"secureGatewayArch": "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
"atomicNotifications": "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
"borgSshPort": "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
"githubToken": "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
"replicationContext": "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.)."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3962,7 +3994,8 @@
|
||||
"vmsLxcs": "VM/LXC",
|
||||
"when": "Quando",
|
||||
"whenLabel": "Quando",
|
||||
"zfsPools": "Pool ZFS"
|
||||
"zfsPools": "Pool ZFS",
|
||||
"sshPort": "Porta SSH"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "backup.",
|
||||
|
||||
@@ -1164,6 +1164,16 @@
|
||||
"targetApp": "Aplicativo",
|
||||
"targetBoth": "SO + aplicativo",
|
||||
"lastRun": "Última execução: {date}",
|
||||
"rebootRequired": "É necessário reiniciar para concluir a atualização.",
|
||||
"rebootPackages": "Pacotes: {packages}",
|
||||
"viewLog": "Ver registo",
|
||||
"logTitle": "Registo da atualização",
|
||||
"logDescription": "Saída da última atualização agendada de {name} (LXC {vmid}).",
|
||||
"logLoading": "A carregar o registo…",
|
||||
"logEmpty": "Esta execução não produziu qualquer saída.",
|
||||
"logFailed": "Não foi possível carregar o registo da atualização.",
|
||||
"logTruncated": "É apresentado apenas o fim do registo porque excede o limite de visualização.",
|
||||
"closeLog": "Fechar",
|
||||
"runSuccess": "✓ sucesso",
|
||||
"runPartial": "concluída parcialmente",
|
||||
"runFailed": "✗ falhou",
|
||||
@@ -1527,6 +1537,7 @@
|
||||
"editButton": "Editar",
|
||||
"upstreamErrorTimeout": "Tempo limite da rede ao entrar em contato com o upstream",
|
||||
"upstreamErrorNetwork": "Erro de rede: {detail}",
|
||||
"upstreamErrorGithubRateLimit": "O limite de pedidos do GitHub foi atingido. Configure um token opcional em Definições → API do GitHub ou tente novamente mais tarde.",
|
||||
"upstreamErrorGeneric": "falha na verificação upstream: {detail}",
|
||||
"notificationsEnabled": "Notificações de atualização upstream ATIVADAS – clique para silenciar",
|
||||
"notificationsMuted": "notificações de atualização upstream silenciadas – clique para ativar",
|
||||
@@ -1648,6 +1659,20 @@
|
||||
"saveFailed": "Não foi possível salvar a configuração de detecção de atualização LXC.",
|
||||
"purgedMessage": "{count} Entradas LXC removidas do registro. Reativar a detecção irá preenchê-los novamente no próximo ciclo de verificação."
|
||||
},
|
||||
"githubApi": {
|
||||
"title": "API do GitHub",
|
||||
"description": "Autenticação opcional para verificar lançamentos e etiquetas das aplicações registadas.",
|
||||
"configure": "Configurar",
|
||||
"tokenLabel": "Token de acesso pessoal",
|
||||
"tokenPlaceholder": "github_pat_...",
|
||||
"tokenHelp": "O token é armazenado de forma cifrada. O ProxMenux utiliza-o apenas para pedidos de leitura à API do GitHub e não volta a apresentá-lo.",
|
||||
"configured": "Token configurado",
|
||||
"notConfigured": "A utilizar a quota de pedidos anónimos do GitHub",
|
||||
"removeToken": "Remover token",
|
||||
"loadFailed": "Não foi possível carregar a definição da API do GitHub.",
|
||||
"saveFailed": "Não foi possível guardar o token da API do GitHub.",
|
||||
"removeFailed": "Não foi possível remover o token da API do GitHub."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificações",
|
||||
"description": "Configure canais de notificação e filtros de eventos. Receba alertas via Telegram, Gotify, Discord ou Email.",
|
||||
@@ -1922,7 +1947,8 @@
|
||||
"gemini": "Um nível gratuito está disponível, com uma boa relação qualidade/preço.",
|
||||
"ollama": "Usa modelos em seu servidor Ollama. Totalmente local, privado e de operação gratuita.",
|
||||
"openrouter": "Acesso a mais de 100 modelos através de uma chave API."
|
||||
}
|
||||
},
|
||||
"loadModelsFailed": "Não foi possível carregar os modelos — verifica a API key ou o URL do endpoint."
|
||||
},
|
||||
"telegramGuide": {
|
||||
"title": "Guia de configuração do bot do Telegram",
|
||||
@@ -2087,7 +2113,7 @@
|
||||
"backupCodeHint": "Você também pode usar um código de backup (formato: XXXX-XXXX)",
|
||||
"backToLogin": "Voltar ao login",
|
||||
"verifyCode": "Verifique o código",
|
||||
"version": "ProxMenux Monitor v1.2.5"
|
||||
"version": "ProxMenux Monitor v1.2.6"
|
||||
},
|
||||
"account": {
|
||||
"signedIn": "Conectado",
|
||||
@@ -3143,7 +3169,13 @@
|
||||
"appsDashboard": "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
|
||||
"lxcAppsUpdates": "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
|
||||
"multilingual": "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298)."
|
||||
"nvidiaMultiGpu": "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
|
||||
"aiCustomEndpoint": "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
"secureGatewayArch": "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
"atomicNotifications": "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
"borgSshPort": "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
"githubToken": "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
"replicationContext": "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.)."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3962,7 +3994,8 @@
|
||||
"vmsLxcs": "VMs/LXCs",
|
||||
"when": "Quando",
|
||||
"whenLabel": "Quando",
|
||||
"zfsPools": "Conjuntos ZFS"
|
||||
"zfsPools": "Conjuntos ZFS",
|
||||
"sshPort": "Porta SSH"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "cópias de segurança.",
|
||||
|
||||
@@ -1163,6 +1163,16 @@
|
||||
"targetApp": "Aplikácia",
|
||||
"targetBoth": "Systém + aplikácia",
|
||||
"lastRun": "Posledné spustenie: {date}",
|
||||
"rebootRequired": "Na dokončenie aktualizácie je potrebný reštart.",
|
||||
"rebootPackages": "Balíky: {packages}",
|
||||
"viewLog": "Zobraziť záznam",
|
||||
"logTitle": "Záznam aktualizácie",
|
||||
"logDescription": "Výstup poslednej naplánovanej aktualizácie {name} (LXC {vmid}).",
|
||||
"logLoading": "Načítava sa záznam…",
|
||||
"logEmpty": "Toto spustenie nevytvorilo žiadny výstup.",
|
||||
"logFailed": "Záznam aktualizácie sa nepodarilo načítať.",
|
||||
"logTruncated": "Zobrazuje sa iba koniec záznamu, pretože prekračuje limit zobrazenia.",
|
||||
"closeLog": "Zavrieť",
|
||||
"runSuccess": "✓ úspešné",
|
||||
"runPartial": "čiastočne dokončené",
|
||||
"runFailed": "✗ zlyhalo",
|
||||
@@ -1526,6 +1536,7 @@
|
||||
"editButton": "Upraviť",
|
||||
"upstreamErrorTimeout": "Časový limit siete pri kontaktovaní upstream",
|
||||
"upstreamErrorNetwork": "Chyba siete: {detail}",
|
||||
"upstreamErrorGithubRateLimit": "Bol dosiahnutý limit požiadaviek GitHubu. V časti Nastavenia → GitHub API nakonfigurujte voliteľný token alebo to skúste znova neskôr.",
|
||||
"upstreamErrorGeneric": "Kontrola proti prúdu zlyhala: {detail}",
|
||||
"notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ – kliknutím ich stlmíte",
|
||||
"notificationsMuted": "Upstream upozornenia na aktualizácie MUTED – kliknutím aktivujete",
|
||||
@@ -1647,6 +1658,20 @@
|
||||
"saveFailed": "Nastavenie kontroly aktualizácií LXC sa nepodarilo uložiť.",
|
||||
"purgedMessage": "Z registra bolo odstránených {count} LXC záznamov. Po opätovnom zapnutí kontroly sa doplnia pri ďalšom skenovaní."
|
||||
},
|
||||
"githubApi": {
|
||||
"title": "GitHub API",
|
||||
"description": "Voliteľné overenie pre kontrolu vydaní a značiek registrovaných aplikácií.",
|
||||
"configure": "Nastaviť",
|
||||
"tokenLabel": "Osobný prístupový token",
|
||||
"tokenPlaceholder": "github_pat_...",
|
||||
"tokenHelp": "Token sa ukladá šifrovane. ProxMenux ho používa iba na požiadavky GitHub API určené na čítanie a po uložení ho už nezobrazí.",
|
||||
"configured": "Token je nastavený",
|
||||
"notConfigured": "Používa sa anonymná kvóta požiadaviek GitHubu",
|
||||
"removeToken": "Odstrániť token",
|
||||
"loadFailed": "Nastavenie GitHub API sa nepodarilo načítať.",
|
||||
"saveFailed": "Token GitHub API sa nepodarilo uložiť.",
|
||||
"removeFailed": "Token GitHub API sa nepodarilo odstrániť."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifikácie",
|
||||
"description": "Nastavte kanály a filtre udalostí. Upozornenia môžete dostávať cez Telegram, Gotify, Discord alebo e-mail.",
|
||||
@@ -1921,7 +1946,8 @@
|
||||
"gemini": "Ponúka bezplatnú úroveň a dobrý pomer kvality a ceny.",
|
||||
"ollama": "Používa modely na vašom Ollama serveri. Beží lokálne, súkromne a bez poplatkov.",
|
||||
"openrouter": "Prístup k viac než 100 modelom cez jeden API kľúč."
|
||||
}
|
||||
},
|
||||
"loadModelsFailed": "Modely sa nepodarilo načítať — skontroluj API kľúč alebo URL endpointu."
|
||||
},
|
||||
"telegramGuide": {
|
||||
"title": "Nastavenie Telegram bota",
|
||||
@@ -2086,7 +2112,7 @@
|
||||
"backupCodeHint": "Môžete použiť aj záložný kód vo formáte XXXX-XXXX",
|
||||
"backToLogin": "Späť na prihlásenie",
|
||||
"verifyCode": "Overiť kód",
|
||||
"version": "ProxMenux Monitor v1.2.5"
|
||||
"version": "ProxMenux Monitor v1.2.6"
|
||||
},
|
||||
"account": {
|
||||
"signedIn": "Prihlásený",
|
||||
@@ -3142,7 +3168,13 @@
|
||||
"appsDashboard": "Nová hlavná karta Apps — jednotný spúšťač pre každý webový odkaz v uzle. Aplikácie zaregistrované v LXC a používateľské vlastné webové odkazy zdieľajú rovnakú mriežku s kategóriami, vyhľadávaním a priamym prístupom do modálu hostiteľa.",
|
||||
"lxcAppsUpdates": "Karta App v modáli každého LXC registruje nainštalované aplikácie, zachytáva webové odkazy a sleduje verzie. Prepracovaná karta Updates aplikuje aktualizácie OS a aplikácií jediným tlačidlom; Docker Engine a jednotlivé image sledujú rovnaký 24-hodinový cyklus s akciou 'Skontrolovať teraz' na požiadanie.",
|
||||
"multilingual": "Monitor teraz hovorí 8 jazykmi: angličtina, španielčina, nemčina, francúzština, taliančina, portugalčina, švédčina a slovenčina. Veľká vďaka patrí @vaso73 za vybudovanie i18n základov.",
|
||||
"nvidiaMultiGpu": "Životný cyklus NVIDIA driverov prechádza na vlastníctvo podľa presného BDF, takže multi-GPU hostiteľ môže odovzdať jednu kartu do VM a druhú nechať funkčnú v hostiteľovi alebo v LXC, plus výber verzie citlivý na kernel, vetvu a GPU (#298)."
|
||||
"nvidiaMultiGpu": "Životný cyklus NVIDIA driverov prechádza na vlastníctvo podľa presného BDF, takže multi-GPU hostiteľ môže odovzdať jednu kartu do VM a druhú nechať funkčnú v hostiteľovi alebo v LXC, plus výber verzie citlivý na kernel, vetvu a GPU (#298).",
|
||||
"aiCustomEndpoint": "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
"secureGatewayArch": "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
"atomicNotifications": "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
"borgSshPort": "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
"githubToken": "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
"replicationContext": "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.)."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3962,7 +3994,8 @@
|
||||
"vmsLxcs": "VM/LXC",
|
||||
"when": "Kedy",
|
||||
"whenLabel": "Kedy",
|
||||
"zfsPools": "ZFS pooly"
|
||||
"zfsPools": "ZFS pooly",
|
||||
"sshPort": "SSH port"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "zálohy.",
|
||||
|
||||
@@ -1164,6 +1164,16 @@
|
||||
"targetApp": "Tillämpningen",
|
||||
"targetBoth": "OS + applikation",
|
||||
"lastRun": "Senaste körningen: {date}",
|
||||
"rebootRequired": "En omstart krävs för att slutföra uppdateringen.",
|
||||
"rebootPackages": "Paket: {packages}",
|
||||
"viewLog": "Visa logg",
|
||||
"logTitle": "Uppdateringslogg",
|
||||
"logDescription": "Utdata från den senaste schemalagda uppdateringen av {name} (LXC {vmid}).",
|
||||
"logLoading": "Läser in loggen…",
|
||||
"logEmpty": "Den här körningen gav ingen utdata.",
|
||||
"logFailed": "Det gick inte att läsa in uppdateringsloggen.",
|
||||
"logTruncated": "Endast slutet av loggen visas eftersom den överskrider visningsgränsen.",
|
||||
"closeLog": "Stäng",
|
||||
"runSuccess": "✓ framgång",
|
||||
"runPartial": "delvis slutförd",
|
||||
"runFailed": "✗ misslyckades",
|
||||
@@ -1527,6 +1537,7 @@
|
||||
"editButton": "Redigera",
|
||||
"upstreamErrorTimeout": "Nätverkstimeout vid kontakt uppströms",
|
||||
"upstreamErrorNetwork": "Nätverksfel: {detail}",
|
||||
"upstreamErrorGithubRateLimit": "GitHubs förfrågningsgräns har nåtts. Konfigurera en valfri token under Inställningar → GitHub API eller försök igen senare.",
|
||||
"upstreamErrorGeneric": "Uppströmskontroll misslyckades: {detail}",
|
||||
"notificationsEnabled": "Uppströmsuppdateringsmeddelanden PÅ – klicka för att stänga av ljudet",
|
||||
"notificationsMuted": "Uppströmsuppdateringsmeddelanden AVSTÄLLD – klicka för att aktivera",
|
||||
@@ -1648,6 +1659,20 @@
|
||||
"saveFailed": "Det gick inte att spara inställningen för upptäckt av LXC-uppdatering.",
|
||||
"purgedMessage": "{count} LXC-poster har tagits bort från registret. Om detektering återaktiveras kommer de att fyllas på igen vid nästa skanningscykel."
|
||||
},
|
||||
"githubApi": {
|
||||
"title": "GitHub API",
|
||||
"description": "Valfri autentisering för kontroller av utgåvor och taggar för registrerade appar.",
|
||||
"configure": "Konfigurera",
|
||||
"tokenLabel": "Personlig åtkomsttoken",
|
||||
"tokenPlaceholder": "github_pat_...",
|
||||
"tokenHelp": "Token lagras krypterad. ProxMenux använder den endast för skrivskyddade anrop till GitHub API och visar den inte igen efter att den har sparats.",
|
||||
"configured": "Token konfigurerad",
|
||||
"notConfigured": "GitHubs anonyma förfrågningskvot används",
|
||||
"removeToken": "Ta bort token",
|
||||
"loadFailed": "Det gick inte att läsa in GitHub API-inställningen.",
|
||||
"saveFailed": "Det gick inte att spara GitHub API-token.",
|
||||
"removeFailed": "Det gick inte att ta bort GitHub API-token."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Aviseringar",
|
||||
"description": "Konfigurera aviseringskanaler och händelsefilter. Ta emot varningar via Telegram, Gotify, Discord eller e-post.",
|
||||
@@ -1922,7 +1947,8 @@
|
||||
"gemini": "En gratis nivå är tillgänglig, med ett bra förhållande mellan kvalitet och pris.",
|
||||
"ollama": "Använder modeller på din Ollama-server. Helt lokalt, privat och gratis att köra.",
|
||||
"openrouter": "Tillgång till mer än 100 modeller via en API-nyckel."
|
||||
}
|
||||
},
|
||||
"loadModelsFailed": "Kunde inte ladda modeller — kontrollera API-nyckeln eller endpoint-URL:en."
|
||||
},
|
||||
"telegramGuide": {
|
||||
"title": "Installationsguide för Telegram bot",
|
||||
@@ -2087,7 +2113,7 @@
|
||||
"backupCodeHint": "Du kan också använda en reservkod (format: XXXX-XXXX)",
|
||||
"backToLogin": "Tillbaka till inloggning",
|
||||
"verifyCode": "Verifiera koden",
|
||||
"version": "ProxMenux Monitor v1.2.5"
|
||||
"version": "ProxMenux Monitor v1.2.6"
|
||||
},
|
||||
"account": {
|
||||
"signedIn": "Inloggad",
|
||||
@@ -3143,7 +3169,13 @@
|
||||
"appsDashboard": "Ny huvudflik Apps — en enda startpunkt för varje webblänk på noden. LXC-registrerade appar och användardefinierade Custom Web Links delar samma rutnät med kategori-taggar, sökning och direktlänk till gästens modal.",
|
||||
"lxcAppsUpdates": "App-fliken inuti varje LXC-modal registrerar installerade appar, fångar webblänkar och spårar uppströmsversioner. Omarbetad Updates-flik applicerar OS-paket och appuppdateringar med en enda knapp; Docker Engine och per-image följer samma 24-timmarscykel med en 'Kontrollera nu'-åtgärd på begäran.",
|
||||
"multilingual": "Monitorn talar nu 8 språk: engelska, spanska, tyska, franska, italienska, portugisiska, svenska och slovakiska. Ett stort tack till @vaso73 för att ha byggt i18n-grunden.",
|
||||
"nvidiaMultiGpu": "NVIDIA-driverns livscykel går över till ägarskap per exakt BDF, så att en multi-GPU-värd kan skicka ett kort till en VM och behålla det andra operativt på värden eller i LXC, plus en versionsväljare som är medveten om kärna, gren och GPU (#298)."
|
||||
"nvidiaMultiGpu": "NVIDIA-driverns livscykel går över till ägarskap per exakt BDF, så att en multi-GPU-värd kan skicka ett kort till en VM och behålla det andra operativt på värden eller i LXC, plus en versionsväljare som är medveten om kärna, gren och GPU (#298).",
|
||||
"aiCustomEndpoint": "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
"secureGatewayArch": "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
"atomicNotifications": "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
"borgSshPort": "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
"githubToken": "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
"replicationContext": "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.)."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3963,7 +3995,8 @@
|
||||
"vmsLxcs": "virtuella datorer/LXC:er",
|
||||
"when": "När",
|
||||
"whenLabel": "När",
|
||||
"zfsPools": "ZFS pooler"
|
||||
"zfsPools": "ZFS pooler",
|
||||
"sshPort": "SSH-port"
|
||||
},
|
||||
"jobs": {
|
||||
"attachDescriptionAfter": "säkerhetskopior.",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -19,8 +19,8 @@ class GeminiProvider(AIProvider):
|
||||
|
||||
# Patterns to exclude from model list (experimental, preview, specialized)
|
||||
EXCLUDED_PATTERNS = [
|
||||
'preview', 'exp', 'experimental', 'computer-use',
|
||||
'deep-research', 'image', 'embedding', 'aqa', 'tts',
|
||||
'computer-use', 'deep-research',
|
||||
'image', 'embedding', 'aqa', 'tts',
|
||||
'learnlm', 'imagen', 'veo'
|
||||
]
|
||||
|
||||
|
||||
@@ -471,7 +471,14 @@ def get_provider_models():
|
||||
if not ok:
|
||||
return jsonify({'success': False, 'models': [], 'message': f'Invalid ollama_url: {err}'}), 400
|
||||
if provider == 'openai' and openai_base_url:
|
||||
ok, err = validate_external_url(openai_base_url, allow_loopback=False)
|
||||
# OpenAI-compatible endpoints (LiteLLM, LM Studio, Ollama-proxy,
|
||||
# LocalAI, vLLM, OmniRoute, opencode.ai, …) are LOCAL by design:
|
||||
# documented deployments run on localhost, on the same LAN or
|
||||
# inside a Docker network — all of which use loopback or
|
||||
# RFC1918 addresses. Blocking them made the "Custom Base URL"
|
||||
# feature unusable in practice (issue #325). The AWS metadata
|
||||
# host stays blocked via _SSRF_BLOCKED_HOSTS regardless.
|
||||
ok, err = validate_external_url(openai_base_url, allow_loopback=True)
|
||||
if not ok:
|
||||
return jsonify({'success': False, 'models': [], 'message': f'Invalid openai_base_url: {err}'}), 400
|
||||
|
||||
@@ -665,7 +672,14 @@ def test_ai_connection():
|
||||
if not ok:
|
||||
return jsonify({'success': False, 'message': f'Invalid ollama_url: {err}', 'model': ''}), 400
|
||||
if provider == 'openai' and openai_base_url:
|
||||
ok, err = validate_external_url(openai_base_url, allow_loopback=False)
|
||||
# OpenAI-compatible endpoints (LiteLLM, LM Studio, Ollama-proxy,
|
||||
# LocalAI, vLLM, OmniRoute, opencode.ai, …) are LOCAL by design:
|
||||
# documented deployments run on localhost, on the same LAN or
|
||||
# inside a Docker network — all of which use loopback or
|
||||
# RFC1918 addresses. Blocking them made the "Custom Base URL"
|
||||
# feature unusable in practice (issue #325). The AWS metadata
|
||||
# host stays blocked via _SSRF_BLOCKED_HOSTS regardless.
|
||||
ok, err = validate_external_url(openai_base_url, allow_loopback=True)
|
||||
if not ok:
|
||||
return jsonify({'success': False, 'message': f'Invalid openai_base_url: {err}', 'model': ''}), 400
|
||||
|
||||
|
||||
@@ -2020,6 +2020,20 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None:
|
||||
if guest_type == 'lxc':
|
||||
_invalidate_lxc_ip(guest_id)
|
||||
if action in ('start', 'reboot'):
|
||||
if guest_type == 'lxc':
|
||||
# TaskWatcher already owns the authoritative lifecycle event.
|
||||
# Reuse it to retire a reboot-required warning left by the last
|
||||
# scheduled update instead of adding another polling loop.
|
||||
try:
|
||||
import lxc_apps
|
||||
lxc_apps.clear_schedule_reboot_required(guest_id)
|
||||
_vm_cache_invalidate(guest_id, _vm_schedule_cache)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f'[ProxMenux] could not clear scheduled-update reboot state '
|
||||
f'for CT {guest_id}: {exc}',
|
||||
flush=True,
|
||||
)
|
||||
_schedule_started_guest_refresh(guest_id, guest_type)
|
||||
return
|
||||
if action == 'stop':
|
||||
@@ -13626,6 +13640,42 @@ def api_vm_apps_schedule(vmid):
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/schedule/log', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_apps_schedule_log(vmid):
|
||||
"""Return the bounded tail of the latest scheduled-update log."""
|
||||
try:
|
||||
import lxc_apps
|
||||
schedule = lxc_apps.get_schedule(vmid) or {}
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'lxc_apps unavailable: {exc}'}), 500
|
||||
name = os.path.basename(str(schedule.get('last_run_log') or ''))
|
||||
if not _LXC_UPDATE_LOG_RE.fullmatch(name) or not name.startswith(f'{vmid}-'):
|
||||
return jsonify({'error': 'no scheduled update log is available'}), 404
|
||||
path = os.path.join(_LXC_UPDATE_LOG_DIR, name)
|
||||
if not os.path.isfile(path):
|
||||
return jsonify({'error': 'scheduled update log was not found'}), 404
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
offset = max(0, size - _LXC_UPDATE_LOG_READ_LIMIT)
|
||||
with open(path, 'rb') as stream:
|
||||
stream.seek(offset)
|
||||
content = stream.read(_LXC_UPDATE_LOG_READ_LIMIT).decode('utf-8', errors='replace')
|
||||
if offset:
|
||||
newline = content.find('\n')
|
||||
if newline >= 0:
|
||||
content = content[newline + 1:]
|
||||
return jsonify({
|
||||
'content': content,
|
||||
'size': size,
|
||||
'truncated': bool(offset),
|
||||
'run_at': schedule.get('last_run_at'),
|
||||
'status': schedule.get('last_run_status'),
|
||||
})
|
||||
except OSError as exc:
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/bulk-update', methods=['GET', 'PUT', 'DELETE'])
|
||||
@require_auth
|
||||
def api_vm_bulk_update(vmid):
|
||||
@@ -13747,6 +13797,54 @@ def api_apps_catalog():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/apps/github-token', methods=['GET'])
|
||||
@require_auth
|
||||
def api_apps_github_token_status():
|
||||
"""Return whether the optional GitHub API token is configured.
|
||||
|
||||
The token itself is deliberately never returned to the client.
|
||||
"""
|
||||
try:
|
||||
if not notification_manager._config:
|
||||
notification_manager._load_config()
|
||||
value = notification_manager._config.get('github_pat', '')
|
||||
return jsonify({'configured': bool(isinstance(value, str) and value.strip())})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/apps/github-token', methods=['PUT'])
|
||||
@require_admin_scope
|
||||
def api_apps_github_token_save():
|
||||
"""Store the GitHub API token in the existing encrypted settings store."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
token = payload.get('token')
|
||||
if not isinstance(token, str):
|
||||
return jsonify({'error': 'token must be a string'}), 400
|
||||
token = token.strip()
|
||||
if not token:
|
||||
return jsonify({'error': 'token is required'}), 400
|
||||
if len(token) > 512:
|
||||
return jsonify({'error': 'token exceeds the 512 character limit'}), 400
|
||||
if any(ch.isspace() or ord(ch) < 33 or ord(ch) == 127 for ch in token):
|
||||
return jsonify({'error': 'token contains whitespace or control characters'}), 400
|
||||
|
||||
result = notification_manager.save_settings({'github_pat': token})
|
||||
if not result.get('success'):
|
||||
return jsonify({'error': result.get('error', 'failed to save token')}), 500
|
||||
return jsonify({'success': True, 'configured': True})
|
||||
|
||||
|
||||
@app.route('/api/apps/github-token', methods=['DELETE'])
|
||||
@require_admin_scope
|
||||
def api_apps_github_token_remove():
|
||||
"""Clear the optional GitHub API token without exposing its old value."""
|
||||
result = notification_manager.save_settings({'github_pat': ''})
|
||||
if not result.get('success'):
|
||||
return jsonify({'error': result.get('error', 'failed to remove token')}), 500
|
||||
return jsonify({'success': True, 'configured': False})
|
||||
|
||||
|
||||
@app.route('/api/lxc-apps/dockerhub-tag-preview', methods=['POST'])
|
||||
@require_auth
|
||||
def api_lxc_apps_dockerhub_tag_preview():
|
||||
@@ -14050,6 +14148,8 @@ def _lxc_update_details(
|
||||
after: dict,
|
||||
verification_pending: bool,
|
||||
verification_errors: list[str],
|
||||
reboot_required: bool | None,
|
||||
reboot_packages: list[str],
|
||||
) -> str:
|
||||
lines = [
|
||||
f"Source: {'Scheduled' if source == 'scheduled' else 'Manual'}",
|
||||
@@ -14122,6 +14222,12 @@ def _lxc_update_details(
|
||||
lines.append('Deferred targets: ' + ', '.join(deferred_targets))
|
||||
if reason:
|
||||
lines.append(f'Reason: {reason}')
|
||||
if reboot_required is True:
|
||||
lines.append('Restart required: yes')
|
||||
if reboot_packages:
|
||||
lines.append('Restart-triggering packages: ' + ', '.join(reboot_packages[:12]))
|
||||
elif reboot_required is False:
|
||||
lines.append('Restart required: no')
|
||||
if verification_pending:
|
||||
lines.append('Verification pending until the container is running')
|
||||
for error in verification_errors[:4]:
|
||||
@@ -14146,6 +14252,8 @@ def _finalize_lxc_update(
|
||||
reason: str | None = None,
|
||||
refresh_docker_inventory: bool = False,
|
||||
before_snapshot: dict | None = None,
|
||||
reboot_required: bool | None = None,
|
||||
reboot_packages=None,
|
||||
) -> dict:
|
||||
safe_run_id = _normalise_lxc_update_run_id(run_id)
|
||||
key = (int(vmid), safe_run_id)
|
||||
@@ -14225,6 +14333,8 @@ def _finalize_lxc_update(
|
||||
after=after,
|
||||
verification_pending=verification_pending,
|
||||
verification_errors=verification_errors,
|
||||
reboot_required=reboot_required,
|
||||
reboot_packages=list(reboot_packages or []),
|
||||
)
|
||||
try:
|
||||
notification_manager.emit_event(
|
||||
@@ -14255,6 +14365,8 @@ def _finalize_lxc_update(
|
||||
'verification_pending': verification_pending,
|
||||
'verification_errors': verification_errors,
|
||||
'docker_inventory': docker_inventory,
|
||||
'reboot_required': reboot_required,
|
||||
'reboot_packages': list(reboot_packages or []),
|
||||
}
|
||||
with _lxc_update_finalization_lock:
|
||||
_lxc_update_finalizations[key] = {
|
||||
@@ -16380,11 +16492,29 @@ def _list_borg_destinations() -> list:
|
||||
encrypt_mode = (parts[3] if len(parts) > 3 else '').strip() or 'repokey'
|
||||
pass_file = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt'
|
||||
has_passphrase = os.path.isfile(pass_file)
|
||||
# Parse ssh://user@host[:port]/path so the frontend can
|
||||
# display the port without having to re-parse the URL.
|
||||
# Non-ssh targets (local paths) leave ssh_port at 0.
|
||||
ssh_port = 0
|
||||
if repo.startswith('ssh://'):
|
||||
after_scheme = repo[len('ssh://'):]
|
||||
at_split = after_scheme.split('@', 1)
|
||||
if len(at_split) == 2:
|
||||
host_and_path = at_split[1]
|
||||
host_part = host_and_path.split('/', 1)[0]
|
||||
if ':' in host_part:
|
||||
try:
|
||||
ssh_port = int(host_part.rsplit(':', 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
ssh_port = 0
|
||||
if ssh_port == 0:
|
||||
ssh_port = 22
|
||||
targets.append({
|
||||
'name': name,
|
||||
'repository': repo,
|
||||
'ssh_key': ssh_key,
|
||||
'ssh_key_path': ssh_key,
|
||||
'ssh_port': ssh_port,
|
||||
'encrypt_mode': encrypt_mode,
|
||||
'has_passphrase': has_passphrase,
|
||||
'jobs_using': _jobs_using_borg(repo),
|
||||
@@ -16649,15 +16779,21 @@ def _capacity_local(path: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _capacity_borg_ssh(host: str, user: str, remote_path: str, key_path: str = '') -> dict:
|
||||
def _capacity_borg_ssh(host: str, user: str, remote_path: str, key_path: str = '',
|
||||
port: int = 22) -> dict:
|
||||
"""Run `df -B1 --output=size,used,avail` over ssh against the
|
||||
remote borg repo path. Times out fast — failure is just rendered
|
||||
as a missing capacity badge in the UI, not a hard error."""
|
||||
as a missing capacity badge in the UI, not a hard error.
|
||||
|
||||
``port`` defaults to 22 (standard SSH); pass a different value for
|
||||
NAS-style hosts that expose SSH on a custom port."""
|
||||
if not host or not user or not remote_path:
|
||||
return {'error': 'incomplete ssh target'}
|
||||
ssh_target = f'{user}@{host}'
|
||||
cmd = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5',
|
||||
'-o', 'StrictHostKeyChecking=accept-new']
|
||||
if port and port != 22:
|
||||
cmd += ['-p', str(int(port))]
|
||||
if key_path:
|
||||
cmd += ['-i', key_path]
|
||||
cmd += [ssh_target, f'df -B1 --output=size,used,avail {shlex.quote(remote_path)}']
|
||||
@@ -18017,11 +18153,17 @@ def api_host_backups_dest_capacity():
|
||||
if kind == 'local' or kind == 'borg-local':
|
||||
cap = _capacity_local((t.get('path') or '').strip())
|
||||
elif kind == 'borg-ssh':
|
||||
port_raw = t.get('port')
|
||||
try:
|
||||
port = int(port_raw) if port_raw not in (None, '', 0, '0') else 22
|
||||
except (TypeError, ValueError):
|
||||
port = 22
|
||||
cap = _capacity_borg_ssh(
|
||||
(t.get('host') or '').strip(),
|
||||
(t.get('user') or '').strip(),
|
||||
(t.get('remote_path') or '').strip(),
|
||||
(t.get('key_path') or '').strip(),
|
||||
port=port,
|
||||
)
|
||||
elif kind == 'pbs':
|
||||
cap = _capacity_pbs(
|
||||
@@ -19219,7 +19361,23 @@ def api_host_backups_dest_borg_add():
|
||||
rpath = (payload.get('ssh_remote_path') or '').strip().lstrip('/')
|
||||
if not user or not host or not rpath:
|
||||
return jsonify({'error': 'ssh_user, ssh_host and ssh_remote_path are required for ssh mode'}), 400
|
||||
repo = f'ssh://{user}@{host}/{rpath}'
|
||||
# Optional custom SSH port — NAS-style hosts often expose SSH on
|
||||
# a non-standard port to reduce noise from bots. Empty / missing
|
||||
# means default 22, which we leave out of the URL so existing
|
||||
# targets stay byte-identical to how the shell installer writes them.
|
||||
raw_port = payload.get('ssh_port')
|
||||
ssh_port = 22
|
||||
if raw_port not in (None, '', 0, '0'):
|
||||
try:
|
||||
ssh_port = int(raw_port)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'error': 'ssh_port must be an integer between 1 and 65535'}), 400
|
||||
if not (1 <= ssh_port <= 65535):
|
||||
return jsonify({'error': 'ssh_port must be an integer between 1 and 65535'}), 400
|
||||
if ssh_port == 22:
|
||||
repo = f'ssh://{user}@{host}/{rpath}'
|
||||
else:
|
||||
repo = f'ssh://{user}@{host}:{ssh_port}/{rpath}'
|
||||
ssh_key = (payload.get('ssh_key_path') or '').strip()
|
||||
elif mode == 'local':
|
||||
repo = (payload.get('repo') or '').strip()
|
||||
@@ -21484,6 +21642,97 @@ _DOCKER_ENGINE_INTEGRATED_COMMAND = (
|
||||
'update_docker_engine.py --vmid "$VMID"'
|
||||
)
|
||||
_scheduled_fired_this_minute: set = set()
|
||||
_LXC_UPDATE_LOG_DIR = "/usr/local/share/proxmenux/logs/lxc-updates"
|
||||
_LXC_UPDATE_LOG_RE = re.compile(r'^[1-9][0-9]*-scheduled-[a-f0-9]{32}\.log$')
|
||||
_LXC_UPDATE_LOG_KEEP_PER_CT = 10
|
||||
_LXC_UPDATE_LOG_READ_LIMIT = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def _create_lxc_update_log(vmid: int, run_id: str) -> tuple[str | None, str | None]:
|
||||
"""Create a private, persistent log for one scheduled LXC run."""
|
||||
name = f'{int(vmid)}-{run_id}.log'
|
||||
if not _LXC_UPDATE_LOG_RE.fullmatch(name):
|
||||
return None, None
|
||||
try:
|
||||
os.makedirs(_LXC_UPDATE_LOG_DIR, mode=0o700, exist_ok=True)
|
||||
path = os.path.join(_LXC_UPDATE_LOG_DIR, name)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', errors='replace') as stream:
|
||||
stream.write(f'=== ProxMenux scheduled LXC update — CT {vmid} ===\n')
|
||||
stream.write(f'Run ID: {run_id}\n')
|
||||
stream.write(f'Started: {datetime.now().astimezone().isoformat()}\n\n')
|
||||
return name, path
|
||||
except OSError as exc:
|
||||
print(f'[ProxMenux] scheduler: could not create update log for CT {vmid}: {exc}',
|
||||
flush=True)
|
||||
return None, None
|
||||
|
||||
|
||||
def _append_lxc_update_log(path: str | None, text: str) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, 'a', encoding='utf-8', errors='replace') as stream:
|
||||
stream.write(text)
|
||||
except OSError as exc:
|
||||
print(f'[ProxMenux] scheduler: could not append update log: {exc}', flush=True)
|
||||
|
||||
|
||||
def _prune_lxc_update_logs(vmid: int) -> None:
|
||||
try:
|
||||
candidates = sorted(
|
||||
glob.glob(os.path.join(_LXC_UPDATE_LOG_DIR, f'{int(vmid)}-scheduled-*.log')),
|
||||
key=os.path.getmtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in candidates[_LXC_UPDATE_LOG_KEEP_PER_CT:]:
|
||||
if _LXC_UPDATE_LOG_RE.fullmatch(os.path.basename(path)):
|
||||
os.unlink(path)
|
||||
except OSError as exc:
|
||||
print(f'[ProxMenux] scheduler: update-log retention failed for CT {vmid}: {exc}',
|
||||
flush=True)
|
||||
|
||||
|
||||
def _inspect_lxc_reboot_requirement(
|
||||
vmid: int,
|
||||
*,
|
||||
update_succeeded: bool,
|
||||
restart_requested: bool,
|
||||
originally_running: bool,
|
||||
) -> tuple[bool | None, list[str], str | None]:
|
||||
"""Read Debian's reboot marker without installing extra guest tools."""
|
||||
if update_succeeded and (restart_requested or not originally_running):
|
||||
return False, [], None
|
||||
if _fast_guest_status(vmid, 'lxc') != 'running':
|
||||
return None, [], 'container is not running; reboot marker could not be checked'
|
||||
try:
|
||||
marker = subprocess.run(
|
||||
['/usr/sbin/pct', 'exec', str(vmid), '--',
|
||||
'test', '-f', '/var/run/reboot-required'],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
|
||||
return None, [], str(exc)
|
||||
if marker.returncode == 1:
|
||||
return False, [], None
|
||||
if marker.returncode != 0:
|
||||
return None, [], (marker.stderr or marker.stdout or 'reboot marker check failed').strip()[:300]
|
||||
packages: list[str] = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['/usr/sbin/pct', 'exec', str(vmid), '--',
|
||||
'cat', '/var/run/reboot-required.pkgs'],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
packages = list(dict.fromkeys(
|
||||
line.strip()[:160]
|
||||
for line in result.stdout.splitlines()
|
||||
if line.strip()
|
||||
))[:32]
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return True, packages, None
|
||||
|
||||
|
||||
def _normalise_schedule_targets(sched: dict) -> list[str]:
|
||||
@@ -21814,16 +22063,40 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
"""Run one scheduled update and finalize it through the shared path."""
|
||||
started_at = time.monotonic()
|
||||
run_id = f'scheduled-{uuid.uuid4().hex}'
|
||||
log_name, log_path = _create_lxc_update_log(vmid, run_id)
|
||||
originally_running = _fast_guest_status(vmid, 'lxc') == 'running'
|
||||
requested_targets = _normalise_schedule_targets(sched)
|
||||
targets = list(requested_targets)
|
||||
before = _lxc_update_snapshot(vmid)
|
||||
deferred_targets: list[str] = []
|
||||
reasons: list[str] = []
|
||||
reboot_required: bool | None = None
|
||||
reboot_packages: list[str] = []
|
||||
reboot_check_error: str | None = None
|
||||
|
||||
def finish(status: str, actual_target: str, executed: list[str]) -> dict:
|
||||
reason = '; '.join(dict.fromkeys(value for value in reasons if value)) or None
|
||||
duration_seconds = max(0, int(time.monotonic() - started_at))
|
||||
labels = _lxc_update_target_labels(requested_targets, [], before)
|
||||
footer = [
|
||||
'',
|
||||
'=== ProxMenux result ===',
|
||||
f'Finished: {datetime.now().astimezone().isoformat()}',
|
||||
f'Status: {status}',
|
||||
f'Duration: {duration_seconds}s',
|
||||
]
|
||||
if reason:
|
||||
footer.append(f'Reason: {reason}')
|
||||
if reboot_required is True:
|
||||
footer.append('Restart required: yes')
|
||||
if reboot_packages:
|
||||
footer.append('Restart-triggering packages: ' + ', '.join(reboot_packages))
|
||||
elif reboot_required is False:
|
||||
footer.append('Restart required: no')
|
||||
elif reboot_check_error:
|
||||
footer.append(f'Restart check unavailable: {reboot_check_error}')
|
||||
_append_lxc_update_log(log_path, '\n'.join(footer) + '\n')
|
||||
_prune_lxc_update_logs(vmid)
|
||||
finalization = _finalize_lxc_update(
|
||||
vmid,
|
||||
run_id=run_id,
|
||||
@@ -21841,6 +22114,8 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
value.startswith('docker-') for value in requested_targets
|
||||
),
|
||||
before_snapshot=before,
|
||||
reboot_required=reboot_required,
|
||||
reboot_packages=reboot_packages,
|
||||
)
|
||||
return {
|
||||
'status': status,
|
||||
@@ -21851,6 +22126,9 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
'executed_targets': list(executed),
|
||||
'deferred_targets': list(deferred_targets),
|
||||
'duration_seconds': duration_seconds,
|
||||
'log_name': log_name,
|
||||
'reboot_required': reboot_required,
|
||||
'reboot_packages': list(reboot_packages),
|
||||
'finalization': finalization,
|
||||
}
|
||||
|
||||
@@ -21969,13 +22247,32 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
"1" if any(value.startswith('docker-') for value in requested_targets) else "0"
|
||||
)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["bash", _APPLY_UPDATES_SCRIPT],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60 * 60, # 1h hard cap so a stuck run doesn't
|
||||
# block the queue forever
|
||||
if log_path:
|
||||
with open(log_path, 'a', encoding='utf-8', errors='replace') as log_stream:
|
||||
r = subprocess.run(
|
||||
["bash", _APPLY_UPDATES_SCRIPT],
|
||||
env=env,
|
||||
stdout=log_stream,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=60 * 60, # 1h hard cap so a stuck run doesn't
|
||||
# block the queue forever
|
||||
)
|
||||
else:
|
||||
r = subprocess.run(
|
||||
["bash", _APPLY_UPDATES_SCRIPT],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60 * 60,
|
||||
)
|
||||
reboot_required, reboot_packages, reboot_check_error = (
|
||||
_inspect_lxc_reboot_requirement(
|
||||
vmid,
|
||||
update_succeeded=r.returncode == 0,
|
||||
restart_requested=bool(sched.get("restart")),
|
||||
originally_running=originally_running,
|
||||
)
|
||||
)
|
||||
if r.returncode != 0:
|
||||
reasons.append(f'update runner exited with code {r.returncode}')
|
||||
@@ -21984,6 +22281,14 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
return finish('partial', target, targets)
|
||||
return finish('success', target, targets)
|
||||
except subprocess.TimeoutExpired:
|
||||
reboot_required, reboot_packages, reboot_check_error = (
|
||||
_inspect_lxc_reboot_requirement(
|
||||
vmid,
|
||||
update_succeeded=False,
|
||||
restart_requested=False,
|
||||
originally_running=originally_running,
|
||||
)
|
||||
)
|
||||
reasons.append('scheduled update timed out')
|
||||
return finish('failure', target, targets)
|
||||
except Exception as exc:
|
||||
@@ -22037,7 +22342,17 @@ def _scheduler_loop():
|
||||
actual_target = outcome.get('target') or 'both'
|
||||
reason = outcome.get('reason')
|
||||
try:
|
||||
lxc_apps.record_schedule_run(_vmid, status, actual_target, reason)
|
||||
lxc_apps.record_schedule_run(
|
||||
_vmid,
|
||||
status,
|
||||
actual_target,
|
||||
reason,
|
||||
log_name=outcome.get('log_name'),
|
||||
reboot_required=outcome.get('reboot_required'),
|
||||
reboot_packages=outcome.get('reboot_packages'),
|
||||
)
|
||||
_vm_cache_invalidate(_vmid, _vm_schedule_cache)
|
||||
_publish_guest_modal_cache_revision(_vmid)
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] scheduler: could not record run for {_vmid}: {e}")
|
||||
print(f"[ProxMenux] scheduler: CT {_vmid} finished with status={status}"
|
||||
|
||||
@@ -220,6 +220,14 @@ class HealthPersistence:
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS notification_delivery_claims (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
claim_token TEXT NOT NULL,
|
||||
claimed_at INTEGER NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS digest_pending (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -287,6 +295,7 @@ class HealthPersistence:
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notif_sent_at ON notification_history(sent_at)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notif_severity ON notification_history(severity)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_nls_ts ON notification_last_sent(last_sent_ts)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notification_claimed_at ON notification_delivery_claims(claimed_at)')
|
||||
|
||||
# ── Disk Observations System ──
|
||||
# Registry of all physical disks seen by the system
|
||||
@@ -419,7 +428,7 @@ class HealthPersistence:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in cursor.fetchall()}
|
||||
required_tables = {'errors', 'events', 'system_capabilities', 'user_settings',
|
||||
'notification_history', 'notification_last_sent',
|
||||
'notification_history', 'notification_last_sent', 'notification_delivery_claims',
|
||||
'disk_registry', 'disk_observations',
|
||||
'excluded_storages', 'excluded_interfaces'}
|
||||
missing = required_tables - tables
|
||||
|
||||
@@ -1285,16 +1285,13 @@ def _select_working_hint_detector(vmid, hint: dict) -> tuple[dict, Optional[str]
|
||||
def _github_pat() -> Optional[str]:
|
||||
try:
|
||||
from notification_manager import notification_manager
|
||||
pat = notification_manager._config.get("github_pat") if notification_manager._config else None
|
||||
if not pat:
|
||||
return None
|
||||
try:
|
||||
from notification_manager import decrypt_sensitive_value
|
||||
if isinstance(pat, str) and pat.startswith("encrypted:"):
|
||||
return decrypt_sensitive_value(pat)
|
||||
except Exception:
|
||||
pass
|
||||
return pat if isinstance(pat, str) else None
|
||||
# The notification manager owns the shared encrypted settings store.
|
||||
# During very early calls its runtime cache may not have been loaded
|
||||
# yet, so initialise it before reading the optional GitHub token.
|
||||
if not notification_manager._config:
|
||||
notification_manager._load_config()
|
||||
pat = notification_manager._config.get("github_pat")
|
||||
return pat.strip() if isinstance(pat, str) and pat.strip() else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -1365,7 +1362,7 @@ def _fetch_github_latest_details(config: dict) -> tuple[Optional[str], Optional[
|
||||
if e.code == 403:
|
||||
remaining = e.headers.get("X-RateLimit-Remaining", "1")
|
||||
if remaining == "0":
|
||||
return None, "github rate limited — configure a PAT in Settings", None
|
||||
return None, "github rate limited — configure a PAT in Settings → GitHub API", None
|
||||
return None, "github rejected the request (403)", None
|
||||
return None, f"github error {e.code}", None
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
@@ -3021,7 +3018,16 @@ def scheduled_app_release_gate(
|
||||
return {"allowed": True, "status": "ready", "reason": None}
|
||||
|
||||
|
||||
def record_schedule_run(vmid, status: str, target: str, reason: Optional[str] = None) -> bool:
|
||||
def record_schedule_run(
|
||||
vmid,
|
||||
status: str,
|
||||
target: str,
|
||||
reason: Optional[str] = None,
|
||||
*,
|
||||
log_name: Optional[str] = None,
|
||||
reboot_required: Optional[bool] = None,
|
||||
reboot_packages: Optional[list[str]] = None,
|
||||
) -> bool:
|
||||
"""Called by the scheduler after a fired run completes. Updates
|
||||
the schedule with last_run_at + last_run_status so the UI can show
|
||||
the outcome. `status` is one of "success" | "failure" |
|
||||
@@ -3037,6 +3043,38 @@ def record_schedule_run(vmid, status: str, target: str, reason: Optional[str] =
|
||||
sidecar["schedule"]["last_run_reason"] = str(reason)[:300]
|
||||
else:
|
||||
sidecar["schedule"].pop("last_run_reason", None)
|
||||
if log_name:
|
||||
sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220]
|
||||
else:
|
||||
sidecar["schedule"].pop("last_run_log", None)
|
||||
if reboot_required is None:
|
||||
sidecar["schedule"].pop("last_run_reboot_required", None)
|
||||
else:
|
||||
sidecar["schedule"]["last_run_reboot_required"] = bool(reboot_required)
|
||||
packages = [
|
||||
str(package).strip()[:160]
|
||||
for package in (reboot_packages or [])[:32]
|
||||
if str(package).strip()
|
||||
]
|
||||
if reboot_required and packages:
|
||||
sidecar["schedule"]["last_run_reboot_packages"] = packages
|
||||
else:
|
||||
sidecar["schedule"].pop("last_run_reboot_packages", None)
|
||||
sidecar["updated_at"] = _now_iso()
|
||||
return _write_sidecar(vmid, sidecar)
|
||||
|
||||
|
||||
def clear_schedule_reboot_required(vmid) -> bool:
|
||||
"""Clear a persisted reboot warning after the CT starts or reboots."""
|
||||
with _cache_lock:
|
||||
sidecar = _read_sidecar(vmid)
|
||||
schedule = (sidecar or {}).get("schedule")
|
||||
if not isinstance(schedule, dict):
|
||||
return False
|
||||
if schedule.get("last_run_reboot_required") is not True:
|
||||
return True
|
||||
schedule["last_run_reboot_required"] = False
|
||||
schedule.pop("last_run_reboot_packages", None)
|
||||
sidecar["updated_at"] = _now_iso()
|
||||
return _write_sidecar(vmid, sidecar)
|
||||
|
||||
|
||||
@@ -4147,6 +4147,17 @@ class ProxmoxHookWatcher:
|
||||
'job_id': pve_job_id,
|
||||
}
|
||||
|
||||
if pve_type == 'replication':
|
||||
replication = self._extract_replication_context(
|
||||
fields, title, message
|
||||
)
|
||||
data.update(replication)
|
||||
entity_id = (
|
||||
replication.get('job_id')
|
||||
or replication.get('vmid')
|
||||
or entity_id
|
||||
)
|
||||
|
||||
# `system_problem` is the generic fallback of `_classify_pve` for
|
||||
# unknown/empty pve_type. Without a populated `reason`, the template
|
||||
# renders "Reason: " (empty) and `_summarize_event` falls back to
|
||||
@@ -4274,6 +4285,73 @@ class ProxmoxHookWatcher:
|
||||
|
||||
self._queue.put(event)
|
||||
return {'accepted': True, 'event_type': event_type, 'event_id': event.event_id}
|
||||
|
||||
def _extract_replication_context(self, fields: dict, title: str,
|
||||
message: str) -> dict:
|
||||
"""Map a native PVE replication notice to template fields."""
|
||||
raw_job_id = fields.get('job-id') or fields.get('job_id') or ''
|
||||
job_id = str(raw_job_id).strip()
|
||||
if not re.fullmatch(r'\d+(?:-\d+)?', job_id):
|
||||
combined = f'{title or ""}\n{message or ""}'
|
||||
match = re.search(
|
||||
r'\breplication(?:\s+job)?(?:\s*:\s*|\s+)'
|
||||
r'[\'\"]?(\d+(?:-\d+)?)\b',
|
||||
combined,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
match = re.search(r'\b(\d+-\d+)\b', combined)
|
||||
job_id = match.group(1) if match else ''
|
||||
|
||||
vmid_match = re.fullmatch(r'(\d+)(?:-\d+)?', job_id)
|
||||
vmid = vmid_match.group(1) if vmid_match else ''
|
||||
vmname = self._resolve_replication_guest_name(vmid)
|
||||
if not vmname and vmid:
|
||||
vmname = 'VM/CT'
|
||||
|
||||
target = str(
|
||||
fields.get('job-target') or fields.get('target') or ''
|
||||
).strip()
|
||||
if not target:
|
||||
target_match = re.search(
|
||||
r'\bwith\s+target\s+[\'\"]([^\'\"\n]+)[\'\"]',
|
||||
message or '',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if target_match:
|
||||
target = target_match.group(1).strip()
|
||||
|
||||
reason_match = re.search(
|
||||
r'^\s*Error:\s*(.*?)\s*\Z',
|
||||
message or '',
|
||||
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
reason = reason_match.group(1).strip() if reason_match else ''
|
||||
if not reason:
|
||||
reason = (message or title or '').strip()
|
||||
|
||||
return {
|
||||
'job_id': job_id,
|
||||
'vmid': vmid,
|
||||
'vmname': vmname,
|
||||
'target_node': target,
|
||||
'reason': reason,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_replication_guest_name(vmid: str) -> str:
|
||||
"""Resolve the replicated guest name from the cluster config."""
|
||||
if not vmid or not vmid.isdigit():
|
||||
return ''
|
||||
for base in ('/etc/pve/qemu-server', '/etc/pve/lxc'):
|
||||
try:
|
||||
with open(f'{base}/{vmid}.conf', encoding='utf-8') as config:
|
||||
for line in config:
|
||||
if line.startswith(('name:', 'hostname:')):
|
||||
return line.split(':', 1)[1].strip()
|
||||
except OSError:
|
||||
continue
|
||||
return ''
|
||||
|
||||
def _classify_pve(self, pve_type: str, severity: str,
|
||||
title: str, message: str) -> tuple:
|
||||
|
||||
@@ -64,6 +64,10 @@ ENCRYPTION_KEY_FILE = Path('/usr/local/share/proxmenux/.notification_key')
|
||||
|
||||
# Keys that contain sensitive data and should be encrypted
|
||||
SENSITIVE_KEYS = {
|
||||
# Optional GitHub API token used by the LXC app version tracker.
|
||||
# It lives in the shared settings store so it benefits from the same
|
||||
# ENC2 encryption and never needs a second secrets file.
|
||||
'github_pat',
|
||||
'ai_api_key', # Legacy - kept for migration
|
||||
'ai_api_key_groq',
|
||||
'ai_api_key_gemini',
|
||||
@@ -388,6 +392,8 @@ DEFAULT_COOLDOWNS = {
|
||||
'updates': 86400,
|
||||
}
|
||||
|
||||
_DELIVERY_CLAIM_TTL = 900
|
||||
|
||||
|
||||
# ─── Storm Protection ────────────────────────────────────────────
|
||||
|
||||
@@ -803,6 +809,8 @@ class NotificationManager:
|
||||
|
||||
# Cooldown tracking: {fingerprint: last_sent_timestamp}
|
||||
self._cooldowns: Dict[str, float] = {}
|
||||
self._delivery_claims: Dict[str, str] = {}
|
||||
self._delivery_claim_lock = threading.Lock()
|
||||
|
||||
# Storm protection
|
||||
self._group_limiter = GroupRateLimiter()
|
||||
@@ -1213,50 +1221,37 @@ class NotificationManager:
|
||||
except Exception:
|
||||
pass # Continue if check fails
|
||||
|
||||
# Cooldown check (does NOT stamp yet — see audit Tier 6, cooldown order).
|
||||
# If we stamped here, a rate-limit hit or a "no channel enabled for this
|
||||
# event_type" situation would burn a 24h cooldown on a delivery that
|
||||
# never reached anyone.
|
||||
if not self._passes_cooldown(event):
|
||||
claim_token = self._claim_delivery(event)
|
||||
if claim_token is None:
|
||||
return
|
||||
delivered = False
|
||||
try:
|
||||
template = TEMPLATES.get(event.event_type, {})
|
||||
group = template.get('group', 'other')
|
||||
if not self._group_limiter.allow(group):
|
||||
return
|
||||
|
||||
# Group rate limit check.
|
||||
template = TEMPLATES.get(event.event_type, {})
|
||||
group = template.get('group', 'other')
|
||||
if not self._group_limiter.allow(group):
|
||||
return
|
||||
|
||||
# Use the properly mapped severity from the event, not from template defaults.
|
||||
# event.severity was set by _map_severity which normalises to CRITICAL/WARNING/INFO.
|
||||
severity = event.severity
|
||||
|
||||
# Inject the canonical severity into data so templates see it too.
|
||||
event.data['severity'] = severity
|
||||
|
||||
# Render message from template (structured output)
|
||||
rendered = render_template(event.event_type, event.data)
|
||||
|
||||
# Enrich data with structured fields for channels that support them
|
||||
enriched_data = dict(event.data)
|
||||
enriched_data['_rendered_fields'] = rendered.get('fields', [])
|
||||
enriched_data['_body_html'] = rendered.get('body_html', '')
|
||||
enriched_data['_event_type'] = event.event_type
|
||||
enriched_data['_group'] = TEMPLATES.get(event.event_type, {}).get('group', 'other')
|
||||
|
||||
# Pass journal context if available (for AI enrichment)
|
||||
if '_journal_context' in event.data:
|
||||
enriched_data['_journal_context'] = event.data['_journal_context']
|
||||
|
||||
# Send through all active channels (AI applied per-channel with detail_level).
|
||||
# Stamp cooldown only if at least one channel actually delivered — otherwise
|
||||
# a misconfigured per-channel toggle would silently lock the event under a
|
||||
# 24h cooldown until someone re-enables it. Audit Tier 6.
|
||||
delivered = self._dispatch_to_channels(
|
||||
rendered['title'], rendered['body'], severity,
|
||||
event.event_type, enriched_data, event.source
|
||||
)
|
||||
if delivered:
|
||||
self._record_cooldown(event.fingerprint)
|
||||
severity = event.severity
|
||||
event.data['severity'] = severity
|
||||
rendered = render_template(event.event_type, event.data)
|
||||
|
||||
enriched_data = dict(event.data)
|
||||
enriched_data['_rendered_fields'] = rendered.get('fields', [])
|
||||
enriched_data['_body_html'] = rendered.get('body_html', '')
|
||||
enriched_data['_event_type'] = event.event_type
|
||||
enriched_data['_group'] = TEMPLATES.get(event.event_type, {}).get('group', 'other')
|
||||
|
||||
if '_journal_context' in event.data:
|
||||
enriched_data['_journal_context'] = event.data['_journal_context']
|
||||
|
||||
delivered = self._dispatch_to_channels(
|
||||
rendered['title'], rendered['body'], severity,
|
||||
event.event_type, enriched_data, event.source
|
||||
)
|
||||
finally:
|
||||
self._finish_delivery_claim(
|
||||
event.fingerprint, claim_token, delivered=delivered,
|
||||
)
|
||||
|
||||
def _dispatch_to_channels(self, title: str, body: str, severity: str,
|
||||
event_type: str, data: Dict, source: str) -> bool:
|
||||
@@ -1884,17 +1879,7 @@ class NotificationManager:
|
||||
print(f"[NotificationManager] quiet cleanup failed for "
|
||||
f"{ch_name}: {e}")
|
||||
|
||||
def _passes_cooldown(self, event: NotificationEvent) -> bool:
|
||||
"""Check if the event passes cooldown rules WITHOUT stamping.
|
||||
|
||||
Splits the historical `_check_cooldown` into a pure predicate plus
|
||||
`_record_cooldown` (separate stamp). Lets the caller check rate-limit
|
||||
and per-channel filters first — if any of those drop the event, we
|
||||
avoid burning a 24h cooldown on a delivery that never happened.
|
||||
Audit Tier 6 (Notification stack #4 + cooldown/per-channel interaction).
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
def _cooldown_seconds(self, event: NotificationEvent) -> int:
|
||||
# Determine cooldown period
|
||||
template = TEMPLATES.get(event.event_type, {})
|
||||
group = template.get('group', 'system')
|
||||
@@ -1958,15 +1943,112 @@ class NotificationManager:
|
||||
_URGENT_EVENTS = {'system_shutdown', 'system_reboot'}
|
||||
if event.event_type in _URGENT_EVENTS and cooldown_str is None:
|
||||
cooldown = 5
|
||||
|
||||
# Check against last sent time using stable fingerprint. Stamp is
|
||||
# deferred to `_record_cooldown()` — only invoked once the event has
|
||||
# passed rate-limit AND at least one channel actually delivered it.
|
||||
|
||||
return cooldown
|
||||
|
||||
def _passes_cooldown(self, event: NotificationEvent) -> bool:
|
||||
"""Check the in-memory cooldown without reserving a delivery."""
|
||||
now = time.time()
|
||||
cooldown = self._cooldown_seconds(event)
|
||||
last_sent = self._cooldowns.get(event.fingerprint, 0)
|
||||
if now - last_sent < cooldown:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _claim_delivery(self, event: NotificationEvent) -> Optional[str]:
|
||||
"""Reserve an event fingerprint before any slow channel work begins."""
|
||||
fingerprint = event.fingerprint
|
||||
now = time.time()
|
||||
token = f'{os.getpid()}:{threading.get_ident()}:{time.time_ns()}'
|
||||
|
||||
with self._delivery_claim_lock:
|
||||
if fingerprint in self._delivery_claims:
|
||||
return None
|
||||
if not self._passes_cooldown(event):
|
||||
return None
|
||||
self._delivery_claims[fingerprint] = token
|
||||
|
||||
allowed = True
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.execute('PRAGMA busy_timeout=5000')
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS notification_delivery_claims (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
claim_token TEXT NOT NULL,
|
||||
claimed_at INTEGER NOT NULL
|
||||
)
|
||||
''')
|
||||
conn.execute(
|
||||
'DELETE FROM notification_delivery_claims WHERE claimed_at < ?',
|
||||
(int(now - _DELIVERY_CLAIM_TTL),),
|
||||
)
|
||||
row = conn.execute(
|
||||
'SELECT last_sent_ts FROM notification_last_sent WHERE fingerprint = ?',
|
||||
(fingerprint,),
|
||||
).fetchone()
|
||||
if row and now - float(row[0]) < self._cooldown_seconds(event):
|
||||
self._cooldowns[fingerprint] = float(row[0])
|
||||
allowed = False
|
||||
else:
|
||||
cursor = conn.execute('''
|
||||
INSERT OR IGNORE INTO notification_delivery_claims
|
||||
(fingerprint, claim_token, claimed_at) VALUES (?, ?, ?)
|
||||
''', (fingerprint, token, int(now)))
|
||||
allowed = cursor.rowcount == 1
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
print(f'[NotificationManager] Delivery claim fallback: {exc}')
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
if not allowed:
|
||||
with self._delivery_claim_lock:
|
||||
if self._delivery_claims.get(fingerprint) == token:
|
||||
self._delivery_claims.pop(fingerprint, None)
|
||||
return None
|
||||
return token
|
||||
|
||||
def _finish_delivery_claim(self, fingerprint: str, token: str,
|
||||
*, delivered: bool) -> None:
|
||||
"""Commit a delivered cooldown or release an unsuccessful claim."""
|
||||
now = time.time()
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.execute('PRAGMA busy_timeout=5000')
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
if delivered:
|
||||
conn.execute('''
|
||||
INSERT OR REPLACE INTO notification_last_sent
|
||||
(fingerprint, last_sent_ts, count)
|
||||
VALUES (?, ?, COALESCE(
|
||||
(SELECT count + 1 FROM notification_last_sent WHERE fingerprint = ?), 1
|
||||
))
|
||||
''', (fingerprint, int(now), fingerprint))
|
||||
conn.execute('''
|
||||
DELETE FROM notification_delivery_claims
|
||||
WHERE fingerprint = ? AND claim_token = ?
|
||||
''', (fingerprint, token))
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
print(f'[NotificationManager] Delivery claim completion fallback: {exc}')
|
||||
if delivered:
|
||||
self._persist_cooldown(fingerprint, now)
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
with self._delivery_claim_lock:
|
||||
if delivered:
|
||||
self._cooldowns[fingerprint] = now
|
||||
if self._delivery_claims.get(fingerprint) == token:
|
||||
self._delivery_claims.pop(fingerprint, None)
|
||||
|
||||
def _record_cooldown(self, fingerprint: str):
|
||||
"""Stamp the cooldown for a fingerprint that was actually delivered."""
|
||||
now = time.time()
|
||||
|
||||
@@ -361,32 +361,73 @@ def get_available_storages() -> List[Dict[str, Any]]:
|
||||
return storages
|
||||
|
||||
|
||||
def _host_arch() -> str:
|
||||
"""Return the host's dpkg architecture (`amd64`, `arm64`, ...).
|
||||
|
||||
Falls back to mapping `uname -m` when `dpkg --print-architecture` is
|
||||
unavailable — the Alpine LXC template filenames use the dpkg style
|
||||
(`amd64`, `arm64`), so `uname -m`'s `x86_64` / `aarch64` gets
|
||||
translated to that form.
|
||||
"""
|
||||
try:
|
||||
rc, out, _ = _run_pve_cmd(["dpkg", "--print-architecture"], timeout=5)
|
||||
arch = out.strip()
|
||||
if rc == 0 and arch:
|
||||
return arch
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
machine = os.uname().machine.lower()
|
||||
except Exception:
|
||||
machine = ''
|
||||
return {
|
||||
'x86_64': 'amd64', 'amd64': 'amd64',
|
||||
'aarch64': 'arm64', 'arm64': 'arm64',
|
||||
'armv7l': 'armhf', 'armhf': 'armhf',
|
||||
'i686': 'i386', 'i386': 'i386',
|
||||
}.get(machine, 'amd64')
|
||||
|
||||
|
||||
def _download_alpine_template(storage: str = DEFAULT_STORAGE) -> bool:
|
||||
"""Download the latest Alpine LXC template using pveam."""
|
||||
"""Download the latest Alpine LXC template using pveam.
|
||||
|
||||
Filters by the host's architecture so an x86_64 host does not end up
|
||||
with an arm64 template — the previous naive `for line in out` loop
|
||||
kept the last match and could pick any arch that `pveam available`
|
||||
happened to list (issue #324).
|
||||
"""
|
||||
print("[*] Downloading Alpine Linux template...")
|
||||
logger.info("Downloading Alpine template via pveam")
|
||||
|
||||
|
||||
host_arch = _host_arch()
|
||||
logger.info(f"Host architecture detected: {host_arch}")
|
||||
|
||||
# Update template list first
|
||||
rc, out, err = _run_pve_cmd(["pveam", "update"], timeout=60)
|
||||
if rc != 0:
|
||||
logger.warning(f"Failed to update template list: {err}")
|
||||
|
||||
|
||||
# Get available Alpine templates
|
||||
rc, out, err = _run_pve_cmd(["pveam", "available", "--section", "system"], timeout=30)
|
||||
if rc != 0:
|
||||
logger.error(f"Failed to list available templates: {err}")
|
||||
return False
|
||||
|
||||
# Find latest Alpine template
|
||||
|
||||
# Find latest Alpine template FOR THIS HOST'S ARCH. Template names
|
||||
# follow `alpine-<ver>-default_<date>_<arch>.tar.xz`; we require the
|
||||
# arch token to match the host before considering the candidate.
|
||||
alpine_template = None
|
||||
arch_token = f"_{host_arch}."
|
||||
for line in out.strip().split('\n'):
|
||||
if 'alpine-' in line.lower():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
alpine_template = parts[1] # Template name is usually second column
|
||||
|
||||
low = line.lower()
|
||||
if 'alpine-' not in low or arch_token not in low:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
alpine_template = parts[1] # Template name is usually second column
|
||||
|
||||
if not alpine_template:
|
||||
logger.error("No Alpine template found in available templates")
|
||||
logger.error(f"No Alpine template found for architecture {host_arch}")
|
||||
return False
|
||||
|
||||
# Download the template
|
||||
@@ -410,11 +451,22 @@ def _find_alpine_template(storage: str = DEFAULT_STORAGE, auto_download: bool =
|
||||
if rc == 0 and out.strip():
|
||||
template_dir = os.path.dirname(out.strip())
|
||||
|
||||
# Look for Alpine templates
|
||||
# Look for Alpine templates matching the host architecture. Without
|
||||
# this filter, a host that happened to have an arm64 Alpine template
|
||||
# sitting in its cache from a previous experiment would be handed
|
||||
# that template — resulting in `arch: arm64` on the container and
|
||||
# the `Exec format error` from issue #324.
|
||||
host_arch = _host_arch()
|
||||
arch_token = f"_{host_arch}."
|
||||
try:
|
||||
templates = os.listdir(template_dir)
|
||||
alpine_templates = [t for t in templates if t.startswith("alpine-") and t.endswith((".tar.xz", ".tar.gz", ".tar"))]
|
||||
|
||||
alpine_templates = [
|
||||
t for t in templates
|
||||
if t.startswith("alpine-")
|
||||
and t.endswith((".tar.xz", ".tar.gz", ".tar"))
|
||||
and arch_token in t.lower()
|
||||
]
|
||||
|
||||
if alpine_templates:
|
||||
# Sort to get latest version
|
||||
alpine_templates.sort(reverse=True)
|
||||
@@ -882,6 +934,7 @@ def deploy_app(app_id: str, config: Dict[str, Any], installed_by: str = "web") -
|
||||
|
||||
pct_cmd = [
|
||||
"pct", "create", str(vmid), template,
|
||||
"--arch", _host_arch(),
|
||||
"--hostname", hostname,
|
||||
"--memory", str(container_def.get("memory", 512)),
|
||||
"--cores", str(container_def.get("cores", 1)),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import notification_events # noqa: E402
|
||||
import notification_templates # noqa: E402
|
||||
|
||||
|
||||
class ReplicationWebhookTests(unittest.TestCase):
|
||||
def _process(self, payload, guest_name='fileserver'):
|
||||
watcher = notification_events.ProxmoxHookWatcher(Queue())
|
||||
with mock.patch.object(
|
||||
watcher,
|
||||
'_resolve_replication_guest_name',
|
||||
return_value=guest_name,
|
||||
), mock.patch.object(
|
||||
notification_events,
|
||||
'capture_journal_context',
|
||||
return_value='',
|
||||
):
|
||||
result = watcher.process_webhook(payload)
|
||||
return result, watcher._queue.get_nowait()
|
||||
|
||||
def test_structured_job_id_populates_template_fields(self):
|
||||
reason = 'command zfs error: cannot open pool\nremote side unavailable'
|
||||
result, event = self._process({
|
||||
'title': "Replication Job: '100-0' failed",
|
||||
'message': (
|
||||
"Replication job '100-0' with target 'pve02' and schedule "
|
||||
"'*/15' failed!\n\n"
|
||||
"Last successful sync: 2026-09-02 15:00:00\n"
|
||||
"Next sync try: 2026-09-02 15:30:00\n"
|
||||
"Failure count: 1\n\n"
|
||||
f"Error:\n{reason}"
|
||||
),
|
||||
'severity': 'error',
|
||||
'fields': {
|
||||
'type': 'replication',
|
||||
'hostname': 'pve01',
|
||||
'job-id': '100-0',
|
||||
},
|
||||
})
|
||||
|
||||
self.assertTrue(result['accepted'])
|
||||
self.assertEqual(event.event_type, 'replication_fail')
|
||||
self.assertEqual(event.entity_id, '100-0')
|
||||
self.assertEqual(event.data['job_id'], '100-0')
|
||||
self.assertEqual(event.data['vmid'], '100')
|
||||
self.assertEqual(event.data['vmname'], 'fileserver')
|
||||
self.assertEqual(event.data['target_node'], 'pve02')
|
||||
self.assertEqual(event.data['reason'], reason)
|
||||
|
||||
rendered = notification_templates.render_template(
|
||||
event.event_type,
|
||||
event.data,
|
||||
)
|
||||
self.assertIn('fileserver (100)', rendered['title'])
|
||||
self.assertIn('ID: 100', rendered['body_text'])
|
||||
self.assertIn(reason, rendered['body_text'])
|
||||
|
||||
def test_title_and_message_are_used_when_job_id_field_is_missing(self):
|
||||
_, event = self._process({
|
||||
'title': "Replication Job: '212-3' failed",
|
||||
'message': (
|
||||
"Replication job '212-3' with target 'pve03' failed!\n\n"
|
||||
"Error: storage 'replica-zfs' is not available"
|
||||
),
|
||||
'severity': 'error',
|
||||
'fields': {'type': 'replication', 'hostname': 'pve01'},
|
||||
}, guest_name='')
|
||||
|
||||
self.assertEqual(event.entity_id, '212-3')
|
||||
self.assertEqual(event.data['vmid'], '212')
|
||||
self.assertEqual(event.data['vmname'], 'VM/CT')
|
||||
self.assertEqual(event.data['target_node'], 'pve03')
|
||||
self.assertEqual(
|
||||
event.data['reason'],
|
||||
"storage 'replica-zfs' is not available",
|
||||
)
|
||||
|
||||
def test_missing_error_block_never_renders_an_empty_reason(self):
|
||||
message = "Replication job '300-0' failed unexpectedly"
|
||||
_, event = self._process({
|
||||
'title': "Replication Job: '300-0' failed",
|
||||
'message': message,
|
||||
'severity': 'error',
|
||||
'fields': {'type': 'replication', 'hostname': 'pve01'},
|
||||
})
|
||||
|
||||
self.assertEqual(event.data['reason'], message)
|
||||
rendered = notification_templates.render_template(
|
||||
event.event_type,
|
||||
event.data,
|
||||
)
|
||||
self.assertIn(f'Reason: {message}', rendered['body_text'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,4 +1,77 @@
|
||||
|
||||
## 2026-09-02
|
||||
|
||||
### New version ProxMenux v1.2.6
|
||||
|
||||
A focused release that restores AI Assistant support for OpenAI-compatible endpoints hosted on private IPs, loopback and Docker networks, aligns the Secure Gateway wizard with the host's real architecture, and consolidates several improvements landing on develop: atomic notification delivery, custom SSH ports for Borg remote targets, an optional GitHub API token for app version tracking, and richer replication failure notifications.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 AI Assistant custom OpenAI endpoint — LAN / Docker / localhost URLs
|
||||
|
||||
- Custom OpenAI-compatible endpoints reachable on private IPs, loopback or Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies…) are now accepted by the Notifications API when loading the model catalogue and validating the AI configuration.
|
||||
- The dropdown surfaces the reason returned by the server (or the underlying network error) directly under the *Load* button, so misconfigurations are visible instead of silent.
|
||||
- Translated into every Monitor language.
|
||||
|
||||
Reported in [#325](https://github.com/MacRimi/ProxMenux/issues/325) by [@jorgeffonte](https://github.com/jorgeffonte).
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Secure Gateway wizard — LXC template matches host architecture
|
||||
|
||||
- Alpine template download filters `pveam available` results by the host's architecture (via `dpkg --print-architecture`, falling back to `uname -m`), so an x86_64 Proxmox host receives the `amd64` template and an arm64 host receives the `arm64` template.
|
||||
- Local template selection applies the same architecture filter when reusing a previously downloaded Alpine template.
|
||||
- `pct create` is invoked with an explicit `--arch <host>` so the container metadata matches the host's real architecture.
|
||||
|
||||
Reported in [#324](https://github.com/MacRimi/ProxMenux/issues/324) by [@N0X4DD0](https://github.com/N0X4DD0).
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Atomic notification delivery
|
||||
|
||||
- Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or accidental parallel Monitor processes cannot send the same event twice.
|
||||
- The reservation is shared through SQLite, expires safely if an execution is interrupted and is released when no channel succeeds, preserving retries after temporary transport failures.
|
||||
|
||||
---
|
||||
|
||||
## 🗄 Borg remote target — custom SSH port
|
||||
|
||||
- The *Add Borg destination* dialog in the Monitor and the shell TUI (`menu` → *Host Backup* → *New Borg target*) accept a custom SSH port. The default stays at `22`; any value between 1 and 65535 is embedded in the persisted `ssh://user@host:port/path` URL.
|
||||
- `BORG_RSH` honours the custom port at backup time, so scheduled jobs and manual runs reach the correct port.
|
||||
- The auto key install flow (`generate-auto`) targets the custom port too.
|
||||
- Fully backwards compatible with existing `borg-targets.txt` entries created without an explicit port.
|
||||
- Capacity probes over SSH also honour the custom port, so the *Available* badge stays accurate on non-standard ports.
|
||||
|
||||
Reported in [discussion #236](https://github.com/MacRimi/ProxMenux/discussions/236) by [@songochain](https://github.com/songochain).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 App version tracking — optional GitHub API token
|
||||
|
||||
- **Settings → GitHub API** accepts an optional personal access token for release and tag checks when GitHub's anonymous quota is exhausted.
|
||||
- The token is encrypted at rest, is never returned to the browser and can be replaced or removed independently of the Notifications service.
|
||||
- The anonymous GitHub flow remains the default; a token is not required while the shared quota is available.
|
||||
- The rate-limit error points to the actual setting and is translated in every Monitor language.
|
||||
|
||||
Reported in [discussion #306](https://github.com/MacRimi/ProxMenux/discussions/306) by [@SystemIdleProcess](https://github.com/SystemIdleProcess).
|
||||
|
||||
---
|
||||
|
||||
## 🔁 Replication failure notifications — complete job context
|
||||
|
||||
- Native Proxmox replication webhooks resolve the replication job ID, affected VM/LXC ID and guest name before rendering the notification.
|
||||
- The exact error block supplied by Proxmox is preserved as the reason, including multiline failures, with the complete message retained as a safe fallback when the block is absent.
|
||||
- Replication notifications are identified by their complete job ID, keeping failures from different replication jobs independent during deduplication.
|
||||
|
||||
Reported by Ale R.
|
||||
|
||||
---
|
||||
|
||||
For the full history of changes, see [Releases](https://github.com/MacRimi/ProxMenux/releases).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-09-01
|
||||
|
||||
### New version ProxMenux v1.2.5
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.2.5.0
|
||||
1.2.6
|
||||
|
||||
@@ -3252,6 +3252,7 @@
|
||||
"Pool name matches but GUID differs (fresh ZFS install):": "Poolname stimmt überein, aber GUID unterscheidet sich (neue ZFS-Installation):",
|
||||
"Pool:": "Pool:",
|
||||
"Port": "Hafen",
|
||||
"Port must be a number between 1 and 65535.": "Port muss eine Zahl zwischen 1 und 65535 sein.",
|
||||
"Port:": "Hafen:",
|
||||
"Portal IP and port are correct": "Portal-IP und Port sind korrekt",
|
||||
"Portal is reachable": "Portal ist erreichbar",
|
||||
@@ -3734,6 +3735,7 @@
|
||||
"SSH login failed": "SSH-Anmeldung fehlgeschlagen",
|
||||
"SSH network risk": "SSH-Netzwerkrisiko",
|
||||
"SSH password auth refused on server": "SSH-Passwortauthentifizierung auf dem Server abgelehnt",
|
||||
"SSH port (default 22):": "SSH Port (Standard 22):",
|
||||
"SSH protection (aggressive mode)": "SSH-Schutz (aggressiver Modus)",
|
||||
"STEP 6: Choose commands based on your storage type": "SCHRITT 6: Wählen Sie Befehle basierend auf Ihrem Speichertyp aus",
|
||||
"STEP 9: Cleanup (LVM only)": "SCHRITT 9: Bereinigung (nur LVM)",
|
||||
|
||||
@@ -3252,6 +3252,7 @@
|
||||
"Pool name matches but GUID differs (fresh ZFS install):": "el nombre del grupo coincide pero el GUID difiere (instalación nueva de ZFS):",
|
||||
"Pool:": "Pool",
|
||||
"Port": "Puerto",
|
||||
"Port must be a number between 1 and 65535.": "El puerto debe ser un número entre 1 y 65535.",
|
||||
"Port:": "Puerto:",
|
||||
"Portal IP and port are correct": "La IP y el puerto del portal son correctos",
|
||||
"Portal is reachable": "El portal es accesible",
|
||||
@@ -3734,6 +3735,7 @@
|
||||
"SSH login failed": "Error al iniciar sesión en SSH",
|
||||
"SSH network risk": "Riesgo de red SSH",
|
||||
"SSH password auth refused on server": "Autenticación de contraseña SSH rechazada en el servidor",
|
||||
"SSH port (default 22):": "puerto SSH (predeterminado 22):",
|
||||
"SSH protection (aggressive mode)": "Protección SSH (modo agresivo)",
|
||||
"STEP 6: Choose commands based on your storage type": "PASO 6: Elige comandos según el tipo de almacenamiento",
|
||||
"STEP 9: Cleanup (LVM only)": "PASO 9: Limpieza (solo LVM)",
|
||||
|
||||
@@ -3252,6 +3252,7 @@
|
||||
"Pool name matches but GUID differs (fresh ZFS install):": "le nom du pool correspond mais le GUID diffère (nouvelle installation de ZFS) :",
|
||||
"Pool:": "Piscine:",
|
||||
"Port": "Port",
|
||||
"Port must be a number between 1 and 65535.": "Le port doit être un nombre compris entre 1 et 65 535.",
|
||||
"Port:": "Port:",
|
||||
"Portal IP and port are correct": "L'adresse IP et le port du portail sont corrects",
|
||||
"Portal is reachable": "Le portail est accessible",
|
||||
@@ -3734,6 +3735,7 @@
|
||||
"SSH login failed": "Échec de la connexion SSH",
|
||||
"SSH network risk": "Risque réseau SSH",
|
||||
"SSH password auth refused on server": "authentification par mot de passe SSH refusée sur le serveur",
|
||||
"SSH port (default 22):": "port SSH (par défaut 22) :",
|
||||
"SSH protection (aggressive mode)": "Protection SSH (mode agressif)",
|
||||
"STEP 6: Choose commands based on your storage type": "ÉTAPE 6 : Choisissez les commandes en fonction de votre type de stockage",
|
||||
"STEP 9: Cleanup (LVM only)": "ÉTAPE 9 : Nettoyage (LVM uniquement)",
|
||||
|
||||
@@ -3252,6 +3252,7 @@
|
||||
"Pool name matches but GUID differs (fresh ZFS install):": "il nome del pool corrisponde ma il GUID è diverso (nuova installazione ZFS):",
|
||||
"Pool:": "Piscina:",
|
||||
"Port": "Porta",
|
||||
"Port must be a number between 1 and 65535.": "la porta deve essere un numero compreso tra 1 e 65535.",
|
||||
"Port:": "Porta:",
|
||||
"Portal IP and port are correct": "L'IP e la porta del portale sono corretti",
|
||||
"Portal is reachable": "Il portale è raggiungibile",
|
||||
@@ -3734,6 +3735,7 @@
|
||||
"SSH login failed": "accesso SSH non riuscito",
|
||||
"SSH network risk": "Rischio della rete SSH",
|
||||
"SSH password auth refused on server": "autenticazione password SSH rifiutata sul server",
|
||||
"SSH port (default 22):": "porta SSH (predefinita 22):",
|
||||
"SSH protection (aggressive mode)": "Protezione SSH (modalità aggressiva)",
|
||||
"STEP 6: Choose commands based on your storage type": "PASSO 6: Scegli i comandi in base al tipo di archiviazione",
|
||||
"STEP 9: Cleanup (LVM only)": "PASSO 9: Pulizia (solo LVM)",
|
||||
|
||||
@@ -3252,6 +3252,7 @@
|
||||
"Pool name matches but GUID differs (fresh ZFS install):": "o nome do pool corresponde, mas o GUID é diferente (nova instalação do ZFS):",
|
||||
"Pool:": "Piscina:",
|
||||
"Port": "Porta",
|
||||
"Port must be a number between 1 and 65535.": "A porta deve ser um número entre 1 e 65535.",
|
||||
"Port:": "Porta:",
|
||||
"Portal IP and port are correct": "O IP e a porta do portal estão corretos",
|
||||
"Portal is reachable": "O portal está acessível",
|
||||
@@ -3734,6 +3735,7 @@
|
||||
"SSH login failed": "falha no login SSH",
|
||||
"SSH network risk": "Risco de rede SSH",
|
||||
"SSH password auth refused on server": "autenticação de senha SSH recusada no servidor",
|
||||
"SSH port (default 22):": "porta SSH (padrão 22):",
|
||||
"SSH protection (aggressive mode)": "Proteção SSH (modo agressivo)",
|
||||
"STEP 6: Choose commands based on your storage type": "PASSO 6: Escolha comandos com base no seu tipo de armazenamento",
|
||||
"STEP 9: Cleanup (LVM only)": "PASSO 9: Limpeza (somente LVM)",
|
||||
|
||||
@@ -3252,6 +3252,7 @@
|
||||
"Pool name matches but GUID differs (fresh ZFS install):": "Názov poolu sedí, ale GUID je iné (čerstvá ZFS inštalácia):",
|
||||
"Pool:": "Pool:",
|
||||
"Port": "Port",
|
||||
"Port must be a number between 1 and 65535.": "Port musí byť číslo od 1 do 65535.",
|
||||
"Port:": "Port:",
|
||||
"Portal IP and port are correct": "IP adresa portálu a port sú správne",
|
||||
"Portal is reachable": "Portál je dostupný",
|
||||
@@ -3734,6 +3735,7 @@
|
||||
"SSH login failed": "SSH prihlásenie zlyhalo",
|
||||
"SSH network risk": "Riziko odpojenia SSH",
|
||||
"SSH password auth refused on server": "Server odmietol prihlásenie SSH heslom",
|
||||
"SSH port (default 22):": "SSH port (predvolený 22):",
|
||||
"SSH protection (aggressive mode)": "ochranou SSH (agresívny režim)",
|
||||
"STEP 6: Choose commands based on your storage type": "KROK 6: vyberte príkazy podľa typu úložiska",
|
||||
"STEP 9: Cleanup (LVM only)": "KROK 9: čistenie (iba LVM)",
|
||||
|
||||
@@ -3252,6 +3252,7 @@
|
||||
"Pool name matches but GUID differs (fresh ZFS install):": "Poolnamn matchar men GUID skiljer sig (ny ZFS-installation):",
|
||||
"Pool:": "Slå samman:",
|
||||
"Port": "Hamn",
|
||||
"Port must be a number between 1 and 65535.": "Port måste vara ett tal mellan 1 och 65535.",
|
||||
"Port:": "Hamn:",
|
||||
"Portal IP and port are correct": "Portal IP och port är korrekta",
|
||||
"Portal is reachable": "Portalen är tillgänglig",
|
||||
@@ -3734,6 +3735,7 @@
|
||||
"SSH login failed": "SSH-inloggning misslyckades",
|
||||
"SSH network risk": "Risk för SSH-nätverk",
|
||||
"SSH password auth refused on server": "SSH-lösenordsautentisering nekades på servern",
|
||||
"SSH port (default 22):": "SSH port (standard 22):",
|
||||
"SSH protection (aggressive mode)": "SSH-skydd (aggressivt läge)",
|
||||
"STEP 6: Choose commands based on your storage type": "STEG 6: Välj kommandon baserat på din lagringstyp",
|
||||
"STEP 9: Cleanup (LVM only)": "STEG 9: Rensning (endast LVM)",
|
||||
|
||||
@@ -2163,6 +2163,12 @@ hb_borg_generate_and_install_key() {
|
||||
local borg_user="$1" host="$2" rpath="$3" mode="$4"
|
||||
local _out_var="$5"
|
||||
local -n _out_ref="$_out_var"
|
||||
# Custom SSH port arrives via env var so the callers (this file
|
||||
# itself, in 7+ places) don't have to change their signature. When
|
||||
# unset or 22, ssh uses its default and no `-p` flag is injected.
|
||||
local _port="${HB_BORG_INSTALL_PORT:-22}"
|
||||
local _p_flag=()
|
||||
[[ "$_port" != "22" ]] && _p_flag=(-p "$_port")
|
||||
|
||||
local key_file="$HOME/.ssh/borg_proxmenux_$(echo "$host" | tr './:' '___')_ed25519"
|
||||
local pub_file="${key_file}.pub"
|
||||
@@ -2346,6 +2352,7 @@ hb_borg_generate_and_install_key() {
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
|
||||
-o NumberOfPasswordPrompts=1 -o ConnectTimeout=10 \
|
||||
"${_p_flag[@]}" \
|
||||
"$admin_user@$host" "true" 2>&1) || true
|
||||
if echo "$_probe" | grep -qiE "permission denied[[:space:]]*\(publickey"; then
|
||||
# SSH password auth refused by the server — common when the Borg
|
||||
@@ -2402,6 +2409,7 @@ hb_borg_generate_and_install_key() {
|
||||
local push_rc
|
||||
SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
|
||||
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
|
||||
"${_p_flag[@]}" \
|
||||
"$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1
|
||||
push_rc=$?
|
||||
|
||||
@@ -2509,6 +2517,22 @@ hb_configure_borg_manual() {
|
||||
12 78 "borg" 3>&1 1>&2 2>&3) || return 1
|
||||
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||
# Custom SSH port (defaults to 22). NAS-style hosts often
|
||||
# move SSH off 22 to keep their intrusion warnings quiet.
|
||||
# Accepted range: 1-65535; anything else re-prompts.
|
||||
local port
|
||||
while :; do
|
||||
port=$(dialog --backtitle "ProxMenux" \
|
||||
--inputbox "$(hb_translate "SSH port (default 22):")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "22" 3>&1 1>&2 2>&3) || return 1
|
||||
port="${port//[[:space:]]/}"
|
||||
[[ -z "$port" ]] && port=22
|
||||
if [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )); then
|
||||
break
|
||||
fi
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--msgbox "$(hb_translate "Port must be a number between 1 and 65535.")" 8 60
|
||||
done
|
||||
rpath=$(dialog --backtitle "ProxMenux" \
|
||||
--inputbox "$(hb_translate "Remote repository path:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
||||
@@ -2598,7 +2622,12 @@ hb_configure_borg_manual() {
|
||||
fi
|
||||
;;
|
||||
generate-auto|generate-manual|generate-pct)
|
||||
if ! hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key; then
|
||||
# Thread the custom port to the key-install helper
|
||||
# via env var so the sshpass probe and the actual
|
||||
# push both hit the right port on the Borg server.
|
||||
HB_BORG_INSTALL_PORT="${port:-22}" \
|
||||
hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key
|
||||
if (( $? != 0 )); then
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
@@ -2606,7 +2635,15 @@ hb_configure_borg_manual() {
|
||||
ssh_key=""
|
||||
;;
|
||||
esac
|
||||
repo="ssh://$user@$host/$rpath"
|
||||
# Custom SSH port is embedded in the URL — Borg's ssh://
|
||||
# scheme natively supports `ssh://user@host:port/path`. Port
|
||||
# 22 is left out for backwards compatibility with existing
|
||||
# borg-targets.txt entries that never carried the port.
|
||||
if [[ "${port:-22}" == "22" ]]; then
|
||||
repo="ssh://$user@$host/$rpath"
|
||||
else
|
||||
repo="ssh://$user@$host:$port/$rpath"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -2635,7 +2672,13 @@ hb_configure_borg_manual() {
|
||||
|
||||
_borg_repo_ref_new="$repo"
|
||||
if [[ -n "$ssh_key" ]]; then
|
||||
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
|
||||
local rsh_cmd="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
|
||||
[[ -n "${port:-}" && "$port" != "22" ]] && rsh_cmd="$rsh_cmd -p $port"
|
||||
export BORG_RSH="$rsh_cmd"
|
||||
elif [[ -n "${port:-}" && "$port" != "22" ]]; then
|
||||
# No custom key but non-default port — still need to tell ssh
|
||||
# which port to hit so `borg` doesn't fall back to 22.
|
||||
export BORG_RSH="ssh -o StrictHostKeyChecking=accept-new -p $port"
|
||||
else
|
||||
unset BORG_RSH
|
||||
fi
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.2.5
|
||||
1.2.6
|
||||
|
||||
@@ -1,3 +1,76 @@
|
||||
## 2026-09-02
|
||||
|
||||
### Nueva versión ProxMenux v1.2.6
|
||||
|
||||
Una versión centrada en restaurar el soporte del Asistente IA para endpoints compatibles con OpenAI alojados en IPs privadas, loopback y redes Docker, alinear el asistente de Secure Gateway con la arquitectura real del host, y consolidar varias mejoras que ya venían acumulándose en develop: entrega atómica de notificaciones, puerto SSH personalizado para destinos remotos Borg, token opcional de la API de GitHub para el seguimiento de versiones de aplicaciones y notificaciones de fallo de replicación con contexto completo.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Endpoint OpenAI personalizado del Asistente IA — URLs de LAN / Docker / localhost
|
||||
|
||||
- Los endpoints compatibles con OpenAI accesibles en IPs privadas, loopback o redes Docker (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, proxies autoalojados…) se aceptan al cargar el catálogo de modelos y al validar la configuración de IA.
|
||||
- El desplegable muestra el motivo devuelto por el servidor (o el error de red subyacente) justo debajo del botón *Cargar*, así una configuración incorrecta deja de aparecer como una lista vacía y silenciosa.
|
||||
- Traducido a todos los idiomas del Monitor.
|
||||
|
||||
Reportado en la [issue #325](https://github.com/MacRimi/ProxMenux/issues/325) por [@jorgeffonte](https://github.com/jorgeffonte).
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Asistente Secure Gateway — la plantilla LXC coincide con la arquitectura del host
|
||||
|
||||
- La descarga de la plantilla Alpine filtra los resultados de `pveam available` por la arquitectura del host (mediante `dpkg --print-architecture`, con fallback a `uname -m`), así un host Proxmox x86_64 recibe la plantilla `amd64` y un host arm64 recibe la plantilla `arm64`.
|
||||
- La selección de plantilla local aplica el mismo filtro de arquitectura al reutilizar una plantilla Alpine ya descargada.
|
||||
- `pct create` se invoca con `--arch <host>` explícito para que los metadatos del contenedor reflejen la arquitectura real del host.
|
||||
|
||||
Reportado en la [issue #324](https://github.com/MacRimi/ProxMenux/issues/324) por [@N0X4DD0](https://github.com/N0X4DD0).
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Entrega atómica de notificaciones
|
||||
|
||||
- Los eventos de notificación reservan su huella de deduplicación de forma atómica antes del procesado por IA y del envío por canal, de modo que colectores concurrentes, callbacks de finalización o procesos Monitor paralelos accidentales no pueden enviar el mismo evento dos veces.
|
||||
- La reserva se comparte a través de SQLite, expira de forma segura si una ejecución se interrumpe y se libera cuando ningún canal tiene éxito, preservando los reintentos ante fallos transitorios de transporte.
|
||||
|
||||
---
|
||||
|
||||
## 🗄 Destino remoto Borg — puerto SSH personalizado
|
||||
|
||||
- El diálogo *Añadir destino Borg* del Monitor y el TUI del shell (`menu` → *Host Backup* → *New Borg target*) aceptan un puerto SSH personalizado. El valor por defecto sigue siendo `22`; cualquier valor entre 1 y 65535 se incrusta en la URL `ssh://user@host:port/path` persistida.
|
||||
- `BORG_RSH` respeta el puerto personalizado en el momento del backup, así los jobs programados y las ejecuciones manuales alcanzan el puerto correcto.
|
||||
- El flujo de instalación automática de clave (`generate-auto`) también apunta al puerto personalizado.
|
||||
- Totalmente retrocompatible con las entradas existentes en `borg-targets.txt` creadas sin puerto explícito.
|
||||
- Las sondas de capacidad sobre SSH también respetan el puerto personalizado, así la insignia *Available* permanece precisa en puertos no estándar.
|
||||
|
||||
Reportado en la [discusión #236](https://github.com/MacRimi/ProxMenux/discussions/236) por [@songochain](https://github.com/songochain).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Seguimiento de versiones de aplicaciones — token opcional de la API de GitHub
|
||||
|
||||
- **Settings → GitHub API** acepta un token de acceso personal opcional para las comprobaciones de releases y tags cuando se agota la cuota anónima de GitHub.
|
||||
- El token se guarda cifrado, nunca se devuelve al navegador y puede sustituirse o eliminarse de forma independiente del servicio de Notificaciones.
|
||||
- El flujo anónimo de GitHub sigue siendo el predeterminado; no se requiere un token mientras la cuota compartida sin autenticar esté disponible.
|
||||
- El error de límite de tasa apunta al ajuste real y está traducido en todos los idiomas del Monitor.
|
||||
|
||||
Reportado en la [discusión #306](https://github.com/MacRimi/ProxMenux/discussions/306) por [@SystemIdleProcess](https://github.com/SystemIdleProcess).
|
||||
|
||||
---
|
||||
|
||||
## 🔁 Notificaciones de fallo de replicación — contexto completo del job
|
||||
|
||||
- Los webhooks nativos de replicación de Proxmox resuelven el ID del trabajo de replicación, el ID de la VM/LXC afectada y el nombre del guest antes de renderizar la notificación.
|
||||
- El bloque de error exacto proporcionado por Proxmox se conserva como motivo, incluyendo fallos multilínea, con el mensaje completo como fallback seguro cuando el bloque no está presente.
|
||||
- Las notificaciones de replicación se identifican por su ID de job completo, así los fallos de trabajos de replicación distintos permanecen independientes durante la deduplicación.
|
||||
|
||||
Reportado por Ale R.
|
||||
|
||||
---
|
||||
|
||||
Para el historial completo de cambios, consulta [Releases](https://github.com/MacRimi/ProxMenux/releases).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-09-01
|
||||
|
||||
### Nueva versión ProxMenux v1.2.5
|
||||
|
||||
@@ -140,7 +140,10 @@
|
||||
"items": [
|
||||
"Choose a preset or cron expression, then select exact targets: OS packages, individual apps, Docker Engine, standalone Docker units or Compose service groups.",
|
||||
"A release hold applies only to selected applications with version tracking. Apps without tracking run their updater whenever their schedule is due.",
|
||||
"The last-run state distinguishes success, partial completion, failure, safety hold and a run with nothing pending.",
|
||||
"The last-run state distinguishes success, partial completion, failure, safety hold and a run with nothing pending. After the first scheduled run, <strong>Updates → Scheduled updates → View log</strong> opens the complete output captured from the updater and any child scripts it invoked.",
|
||||
"After each run, ProxMenux checks the standard Debian reboot-required marker. When a restart is needed, the Updates tab and the completion notification say so; the warning is cleared when that LXC starts or restarts.",
|
||||
"On the Proxmox host, scheduled-run logs are stored in <code>/usr/local/share/proxmenux/logs/lxc-updates/</code> using the name <code><VMID>-scheduled-<run-id>.log</code>. The latest ten logs are retained per LXC and older files are removed automatically.",
|
||||
"This retained history applies to scheduled updates. A manual update displays its output live in the Monitor execution window and does not create a scheduled-run log in that directory.",
|
||||
"External host schedules detected from Proxmox VE Helper-Scripts are shown separately so overlapping automation is visible."
|
||||
],
|
||||
"callout": "Run every selected method manually before enabling a schedule. Scheduled commands cannot answer prompts."
|
||||
@@ -150,6 +153,7 @@
|
||||
"lead": "The update is not considered finished when the terminal command merely exits.",
|
||||
"items": [
|
||||
"The same run records its final result and refreshes OS package state, registered app versions and Docker inventory as applicable.",
|
||||
"Scheduled runs retain their terminal output and report whether a restart is still required to finish applying package changes.",
|
||||
"The LXC cache is replaced with the verified post-update state, so badges and buttons do not retain the previous result.",
|
||||
"If a stopped or restored LXC starts, the existing lifecycle event refreshes that LXC again. Docker inventory waits for the daemon to become ready instead of caching an empty startup result as final.",
|
||||
"Enabled notifications are emitted from the finalized run, including partial failures and grouped Docker image results."
|
||||
@@ -179,6 +183,10 @@
|
||||
{
|
||||
"problem": "A custom command fails",
|
||||
"resolution": "Run it in the LXC terminal and review its path, dependencies, non-interactive flags and exit code."
|
||||
},
|
||||
{
|
||||
"problem": "A scheduled update says that a restart is required",
|
||||
"resolution": "Open View log to review the completed run, then restart that LXC. ProxMenux clears the warning from the existing lifecycle event after the container starts again."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -140,7 +140,10 @@
|
||||
"items": [
|
||||
"Selecciona una frecuencia o expresión cron y después objetivos exactos: paquetes del SO, apps individuales, Docker Engine, unidades Docker independientes o grupos de servicios Compose.",
|
||||
"La espera tras una versión solo se aplica a las apps seleccionadas con seguimiento. Las apps sin seguimiento ejecutan su actualizador cuando vence la programación.",
|
||||
"El estado de la última ejecución diferencia entre éxito, finalización parcial, error, retención de seguridad y ausencia de elementos pendientes.",
|
||||
"El estado de la última ejecución diferencia entre éxito, finalización parcial, error, retención de seguridad y ausencia de elementos pendientes. Después de la primera ejecución programada, <strong>Actualizaciones → Actualizaciones programadas → Ver log</strong> abre la salida completa capturada del actualizador y de los scripts secundarios que haya ejecutado.",
|
||||
"Después de cada ejecución, ProxMenux comprueba el marcador estándar de Debian que indica si es necesario reiniciar. Cuando hace falta, la pestaña Actualizaciones y la notificación de finalización lo indican; el aviso se elimina cuando ese LXC se inicia o reinicia.",
|
||||
"En el host Proxmox, los logs de las ejecuciones programadas se guardan en <code>/usr/local/share/proxmenux/logs/lxc-updates/</code> con el nombre <code><VMID>-scheduled-<id-de-ejecución>.log</code>. Se conservan los diez últimos logs de cada LXC y los archivos más antiguos se eliminan automáticamente.",
|
||||
"Este historial corresponde a las actualizaciones programadas. Una actualización manual muestra su salida en tiempo real en la ventana de ejecución del Monitor y no crea un log de ejecución programada en ese directorio.",
|
||||
"Las programaciones externas detectadas de Proxmox VE Helper-Scripts se muestran aparte para hacer visible cualquier automatización coincidente."
|
||||
],
|
||||
"callout": "Cada método seleccionado debe probarse manualmente antes de programarlo. Una tarea programada no puede responder a preguntas interactivas."
|
||||
@@ -150,6 +153,7 @@
|
||||
"lead": "La actualización no se considera terminada únicamente porque el comando del terminal haya finalizado.",
|
||||
"items": [
|
||||
"La misma ejecución guarda el resultado final y actualiza, según corresponda, los paquetes del SO, las versiones de las apps y el inventario Docker.",
|
||||
"Las ejecuciones programadas conservan la salida del terminal e indican si todavía es necesario reiniciar para terminar de aplicar los cambios de los paquetes.",
|
||||
"La caché del LXC se reemplaza con el estado verificado tras la actualización para que insignias y botones no conserven el resultado anterior.",
|
||||
"Si arranca un LXC parado o restaurado, el evento de ciclo de vida existente vuelve a actualizar ese LXC. El inventario Docker espera a que el daemon esté disponible en lugar de guardar como definitivo un resultado vacío del arranque.",
|
||||
"Las notificaciones activadas se emiten desde la ejecución finalizada e incluyen fallos parciales y resultados agrupados de imágenes Docker."
|
||||
@@ -179,6 +183,10 @@
|
||||
{
|
||||
"problem": "Falla un comando personalizado",
|
||||
"resolution": "Ejecútalo en el terminal del LXC y revisa la ruta, las dependencias, los parámetros no interactivos y el código de salida."
|
||||
},
|
||||
{
|
||||
"problem": "Una actualización programada indica que es necesario reiniciar",
|
||||
"resolution": "Abre <strong>Ver log</strong> para revisar la ejecución completada y reinicia ese LXC. ProxMenux elimina el aviso mediante el evento de ciclo de vida existente cuando el contenedor vuelve a arrancar."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user