overhaul app tracking and update orchestration

- Generate and ship a verified 389-app tracking catalog with 23 runtime overrides, fallback detectors, ports, logos, and Docker Hub tag previews.
- Support modern Proxmox VE Helper-Scripts markers, historical installations, and official or manual app deployments.
- Rework the LXC App and Updates tabs with cached suggestions, explicit discovery, version tracking, web links, custom updaters, and complete i18n.
- Add independent OS, app, Docker Engine, Docker image, bulk, and scheduled update targets.
- Add digest-based Docker inventory, Compose dependency grouping, safe standalone-container recreation with rollback, and package-scoped Docker Engine updates.
- Refresh per-LXC caches after lifecycle and update tasks, then emit idempotent notifications based on the verified final state.
- Harden Coral USB recovery by removing orphaned gasket DKMS registrations and validating that dpkg is healthy before reporting success.
This commit is contained in:
MacRimi
2026-08-23 12:43:03 +02:00
parent 7244201810
commit 0251f77331
27 changed files with 11631 additions and 893 deletions
@@ -655,6 +655,23 @@ def build_catalog(
release, release_candidates, release_error = select_release_candidate(
slug, app_name, launcher, installer
)
# Some current helpers do not install through
# fetch_and_deploy_gh_release, but explicitly write the release to
# the same marker used by check_for_gh_release. Actual Budget is a
# representative example (npm install + ~/.actualbudget). Recover
# the repository from the release check only when that marker maps
# to one unique candidate; never guess it from a generic header.
marker_release_repo: str | None = None
if len(relevant_markers) == 1:
marker_path = f"/root/{relevant_markers[0]}".lower()
marker_repos = {
str(candidate.get("repo") or "")
for candidate in release_candidates
if str(candidate.get("cache_file") or "").lower() == marker_path
and candidate.get("repo")
}
if len(marker_repos) == 1:
marker_release_repo = marker_repos.pop()
install_only_release = select_install_only_release(
slug, app_name, installer, header_repos
) if release is None else None
@@ -708,13 +725,13 @@ def build_catalog(
}
continue
if len(relevant_markers) == 1 and helper_repo and helper_version:
if len(relevant_markers) == 1 and marker_release_repo:
marker = relevant_markers[0]
hint = {
"installed_via": "file",
"file_path": f"/root/{marker}",
"file_regex": version_regex,
"repo": helper_repo,
"repo": marker_release_repo,
"github_source": "releases",
"tag_regex": version_regex,
}
@@ -745,7 +762,7 @@ def build_catalog(
records.append(record)
v2_apps[slug] = {
"name": app_name,
"repo": helper_repo,
"repo": marker_release_repo,
"official_sources": official_sources,
"detectors": v2_detectors,
}
@@ -905,13 +922,14 @@ def demote_generic_helper_markers(
v2: dict[str, Any],
audit: dict[str, Any],
) -> list[str]:
"""Remove generic /root/.app caches from the operational catalog.
"""Remove modern helper markers when compatibility-only output is wanted.
Even when install and update scripts both write the marker, it records
helper/update state rather than interrogating the installed application.
Runtime checks also found these files absent on legacy and manually
updated LXC. They remain useful candidates/fallbacks in v2, not verified
primary detectors.
Current community-scripts installers maintain ``/root/.<app>`` as their
version contract, so these are valid detectors for modern helper-owned
containers. They are not universal: legacy helpers and official/manual
installs may not have them. The default conservative mode therefore keeps
them in v2; production generation can opt in with
``--include-helper-markers`` and the runtime reports their distinct source.
"""
demoted: list[str] = []
apps = v2.get("apps", {})
@@ -927,12 +945,12 @@ def demote_generic_helper_markers(
continue
catalog.pop(slug, None)
record["status"] = "candidate"
record["reason"] = "generic helper marker is not guaranteed on legacy/manual installations"
record["reason"] = "modern helper marker is not guaranteed on legacy/manual installations"
for detector in (apps.get(slug) or {}).get("detectors", []):
if detector.get("installed_via") == "file" and detector.get("file_path") == path:
detector["verification"] = "candidate-helper-marker"
detector["limitation"] = (
"Observed absent on legacy/manual LXC; use only as fallback or after runtime probe"
"Valid for modern helper installs; absent on legacy/manual LXC"
)
demoted.append(slug)
return sorted(demoted)
@@ -1147,14 +1165,16 @@ def apply_runtime_overrides(
"""Apply detectors proven against real containers.
The generated/static catalog is intentionally conservative. This optional
overlay promotes only detectors carrying runtime evidence. Unsupported
future methods (for example ``python_dist`` or ``docker_label``) are kept
in v2 but are not written to the current-compatible v1 catalog.
overlay promotes only detectors carrying runtime evidence. The compatible
catalog now supports every detector implemented by ``lxc_apps.py``;
retaining an older dpkg/file/binary-only allow-list silently discarded
proven Python and Docker detectors.
"""
result: dict[str, Any] = {
"file": str(overrides_path) if overrides_path else None,
"promoted_to_v1": [],
"v2_only": [],
"runtime_only": [],
"invalid": [],
}
if overrides_path is None or not overrides_path.is_file():
@@ -1164,13 +1184,17 @@ def apply_runtime_overrides(
if not isinstance(apps_raw, dict):
raise CatalogError("runtime overrides must contain an 'apps' object")
supported_v1 = {"dpkg", "apk", "file", "binary"}
supported_v1 = {
"dpkg", "apk", "file", "binary", "python_dist",
"docker_label", "docker_exec", "command", "manual",
}
v2_apps = v2.get("apps", {})
detector_keys = {
"installed_via", "package", "file_path", "file_regex",
"binary_path", "binary_args", "python_path", "distribution",
"container_name", "label", "repo", "github_source", "tag_regex",
"installed_regex",
"container_name", "label", "command_argv", "installed_version",
"repo", "github_source", "tag_regex", "installed_regex",
"upstream_type", "upstream_url", "upstream_json_path", "docker_image",
}
passthrough_keys = {
"file_fallbacks", "alt_detectors", "default_ports", "logo", "website",
@@ -1193,8 +1217,43 @@ def apply_runtime_overrides(
}
app = v2_apps.get(slug)
if not isinstance(app, dict):
result["invalid"].append(slug)
continue
# Official/manual and nested Docker applications do not
# necessarily have a community-scripts ct/<slug>.sh launcher.
# A runtime-proven override is sufficient to create their v2
# entry; rejecting it here silently reintroduced the old
# helper-only limitation.
display_name = str(spec.get("name") or "").strip()
if not display_name:
display_name = slug.replace("-", " ").replace("_", " ").title()
app = {
"name": display_name,
"repo": detector.get("repo"),
"official_sources": [],
"detectors": [],
}
v2_apps[slug] = app
result["runtime_only"].append(slug)
# Preserve every statically-proven modern community-scripts marker
# when a stronger runtime detector becomes primary. This gives new
# helper installs their official /root/.<app> checker while keeping
# package/binary/python detectors first for legacy/manual installs.
helper_markers: list[dict[str, str]] = []
for candidate in app.get("detectors", []):
marker_path = candidate.get("file_path")
marker_regex = candidate.get("file_regex")
if (
candidate.get("installed_via") == "file"
and isinstance(marker_path, str)
and re.fullmatch(r"/root/\.[A-Za-z0-9_.-]+", marker_path)
and isinstance(marker_regex, str)
and marker_regex
):
helper_markers.append({
"path": marker_path,
"regex": marker_regex,
"source": "helper_marker",
})
app.setdefault("detectors", []).insert(0, v2_detector)
app["runtime_evidence"] = evidence
@@ -1211,20 +1270,35 @@ def apply_runtime_overrides(
for key, value in presentation_source.items()
if key in {"default_ports", "logo", "website"}
}
hint = {k: v for k, v in detector.items() if k not in {
"binary_args", "python_path", "distribution", "container_name",
"label", "installed_regex",
}}
# Every method-specific field is required at runtime. The old
# compatibility filter removed python_path/distribution,
# binary_args and container fields, producing catalog entries
# that validated statically but could never execute.
hint = dict(detector)
for key in passthrough_keys:
if key in spec:
hint[key] = spec[key]
fallbacks = [
dict(item) for item in hint.get("file_fallbacks", [])
if isinstance(item, dict)
]
known_paths = {
item.get("path") for item in fallbacks if isinstance(item.get("path"), str)
}
primary_path = hint.get("file_path") if hint.get("installed_via") == "file" else None
for marker in helper_markers:
if marker["path"] != primary_path and marker["path"] not in known_paths:
fallbacks.append(marker)
known_paths.add(marker["path"])
if fallbacks:
hint["file_fallbacks"] = fallbacks
hint.update(presentation)
catalog[slug] = hint
result["promoted_to_v1"].append(slug)
else:
result["v2_only"].append(slug)
for key in ("promoted_to_v1", "v2_only", "invalid"):
for key in ("promoted_to_v1", "v2_only", "runtime_only", "invalid"):
result[key].sort()
return result
@@ -49,12 +49,15 @@ jobs:
# `--runtime-overrides` folds real-CT evidence into the
# operational hints (canonical paths, cross-method fallbacks
# per app) so the runtime doesn't get fed helper-marker
# false-positives.
# false-positives. Modern helper markers are included as their
# official version contract; runtime keeps them distinguishable from
# canonical package/binary/manual detectors for legacy compatibility.
run: |
python .github/scripts/generate_app_tracking_catalog.py \
--helpers-cache json/helpers_cache.json \
--existing json/app_tracking_hints.json \
--runtime-overrides json/runtime_verified_overrides.json \
--include-helper-markers \
--output json/app_tracking_hints.generated.json \
--v2-output /tmp/app_tracking_catalog.v2.json \
--audit-output /tmp/app_tracking_hints.audit.json
@@ -96,8 +99,9 @@ jobs:
# governs generator-covered slugs.
GENERATOR_FIELDS = {
"installed_via", "package", "file_path", "file_regex",
"binary_path", "repo", "github_source", "tag_regex",
"installed_regex",
"binary_path", "binary_args", "python_path", "distribution",
"container_name", "label", "command_argv", "installed_version",
"repo", "github_source", "tag_regex", "installed_regex",
# Upstream source discriminator + per-type fields
# (http_json + docker_hub). Kept in the whitelist so a
# curated entry in runtime_verified_overrides.json can
+518 -81
View File
@@ -10,10 +10,9 @@
* an optional GitHub repo for upstream version tracking
* a list of ports, each with a description and web path
*
* Docker apps are "register-only": they exist to produce clickable
* links, ProxMenux does NOT try to track their version and NEVER
* emits warnings for them updates for Docker apps are handled by
* Docker itself.
* Docker image updates live exclusively in the Updates tab. The App
* tab only registers the Docker engine/app identity and shows installed
* metadata, so an unregistered detection can never create update noise.
*
* For ProxMenux-managed OCI CTs (Secure Gateway) the panel is
* read-only the actual update lifecycle lives in Security
@@ -24,8 +23,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import {
Loader2, Save, RefreshCw, Trash2, Package, ExternalLink,
AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff,
ArrowUpCircle, RotateCcw, Check, Settings2, ShieldCheck, CheckCircle2,
Bell, BellOff,
ArrowUpCircle, RotateCcw, Check, Settings2, ShieldCheck,
Bell, BellOff, Search,
} from "lucide-react"
import { Card, CardContent } from "./ui/card"
import { Button } from "./ui/button"
@@ -34,7 +33,7 @@ import { Label } from "./ui/label"
import { Badge } from "./ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps } from "../lib/lxc-apps-cache"
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached } from "../lib/lxc-apps-cache"
import { useT } from "@/lib/i18n/provider"
// installed_via is optional now — an empty value means "register only,
@@ -84,6 +83,11 @@ interface AppConfig {
health_path?: string
logo_url?: string
helper_slug?: string
// Preserved here even though this editor does not execute updates.
// The backend uses full-record replacement, so omitting these when
// editing ports/tracking would silently erase the Updates-tab setup.
update_command?: string
hide_no_updater_notice?: boolean
// Per-app opt-out for the `app_update_available` notification.
// Absent / true = notify; false = silenced. Set from the bell
// toggle on each app card and/or the Edit form's checkbox.
@@ -104,6 +108,7 @@ interface DetectedApp {
interface AppState {
installed_version: string | null
latest_version: string | null
latest_published_at?: string | null
update_available: boolean | null
error: string | null
checked_at: string | null
@@ -123,18 +128,77 @@ interface SidecarResponse {
updated_at?: string
}
interface DetectorTestResult {
valid: boolean
persisted: false
checked_at: string
installed: {
configured: boolean
method: InstalledVia | null
effective_regex: string | null
version: string | null
error: string | null
}
upstream: {
configured: boolean
type: "github" | "http_json" | "docker_hub" | null
version: string | null
published_at?: string | null
error: string | null
}
update_available: boolean | null
}
interface TrackingSuggestion {
installed_via: "dpkg" | "apk" | "file" | "binary"
installed_via: Exclude<InstalledVia, "">
package?: string
file_path?: string
file_regex?: string
binary_path?: string
binary_args?: string[]
python_path?: string
distribution?: string
container_name?: string
label?: string
command_argv?: string[]
installed_version?: string
installed_regex?: string
upstream_type?: "github" | "http_json" | "docker_hub" | ""
upstream_url?: string
upstream_json_path?: string
docker_image?: string
repo?: string
github_source?: "releases" | "tags"
tag_regex?: string
detected_version?: string
detector_verified?: boolean
detector_source?: "primary" | "alternative" | "helper_marker" | "legacy_fallback" | "runtime_probe" | "candidate"
detector_error?: string
}
interface DockerTagPreview {
image: string
regex: string
tags: Array<{ tag: string; version: string | null; moving: boolean }>
matched_count: number
scanned_count: number
cached_for_seconds: number
}
interface DockerWebLinkSuggestion {
container_name: string
service_name: string
service_slug?: string | null
image: string
host_port: number
container_port: number
scheme: "http" | "https"
web_path: string
logo_url?: string | null
}
interface Suggestions {
ready?: boolean
name_suggestion: string | null
helper_slug: string | null
port_suggestions: number[]
@@ -143,6 +207,7 @@ interface Suggestions {
default_ports?: number[]
logo_url?: string | null
extras?: DetectedApp[]
docker_web_links?: DockerWebLinkSuggestion[]
}
// Compact catalog entry — one row for every registerable app the
@@ -209,6 +274,44 @@ const EMPTY_APP: AppConfig = {
logo_url: "",
}
const SELFHST_WEBP_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp"
const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: string }> = {
frigate: {
lightTheme: `${SELFHST_WEBP_BASE}/frigate-dark.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/frigate-light.webp`,
},
portainer: {
lightTheme: `${SELFHST_WEBP_BASE}/portainer-dark.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/portainer-light.webp`,
},
vaultwarden: {
lightTheme: `${SELFHST_WEBP_BASE}/vaultwarden.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/vaultwarden-light.webp`,
},
}
function selfhstThemeLogos(src: string) {
if (!src.toLowerCase().includes("cdn.jsdelivr.net/gh/selfhst/icons")) return null
const match = src.toLowerCase().match(/\/(frigate|portainer|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
return match ? SELFHST_THEME_LOGOS[match[1]] : null
}
export function ThemeAwareLogo({ src, className }: { src: string; className: string }) {
const themed = selfhstThemeLogos(src)
const hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.style.display = "none"
}
if (!themed) {
return <img src={src} alt="" className={className} onError={hideBroken} />
}
return (
<>
<img src={themed.lightTheme} alt="" className={`${className} block dark:hidden`} onError={hideBroken} />
<img src={themed.darkTheme} alt="" className={`${className} hidden dark:block`} onError={hideBroken} />
</>
)
}
// Default scheme heuristic for freshly-added ports — only used to
// pre-select the dropdown. The user always has the final say via
// the http/https selector next to the port input.
@@ -245,9 +348,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
const [error, setError] = useState<string | null>(null)
const [searchingApplications, setSearchingApplications] = useState(false)
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
// Editor state
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
const [saving, setSaving] = useState(false)
const [testingDetector, setTestingDetector] = useState(false)
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
const [busyAppId, setBusyAppId] = useState<string | null>(null)
// Advanced section (version tracking) is collapsed by default so the
// basic Name + Ports flow stays approachable. Auto-expanded when
@@ -270,11 +377,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// restore, this panel is skipped entirely and the button opens the
// editor directly (fast path for the common case).
const [browseOpen, setBrowseOpen] = useState(false)
const [dockerTagPreview, setDockerTagPreview] = useState<DockerTagPreview | null>(null)
const [dockerTagPreviewLoading, setDockerTagPreviewLoading] = useState(false)
const [dockerTagPreviewError, setDockerTagPreviewError] = useState<string | null>(null)
// Global "manage apps" mode. When ON, every app card grows a footer
// with Remove / Check / Edit fields actions. When OFF the cards are
// pure info — the only surfaced action is a hover-reveal Check icon
// on the LATEST UPSTREAM panel. Toggled from a single button next to
// pure info; detector checks remain available after enabling Edit.
// Toggled from a single button next to
// "Add another application".
const [editMode, setEditMode] = useState(false)
@@ -309,6 +419,42 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
useEffect(() => { load() }, [load])
// Live Docker Hub preview. Debounced so typing an image/regex does not
// issue one request per keystroke; the backend additionally caches the
// raw tag list for 60 seconds per image.
useEffect(() => {
const draft = editing?.draft
const image = (draft?.docker_image || "").trim()
if (draft?.upstream_type !== "docker_hub" || !image) {
setDockerTagPreview(null)
setDockerTagPreviewError(null)
setDockerTagPreviewLoading(false)
return
}
let cancelled = false
setDockerTagPreview(null)
setDockerTagPreviewError(null)
const timer = window.setTimeout(async () => {
setDockerTagPreviewLoading(true)
try {
const result: DockerTagPreview = await fetchApi("/api/lxc-apps/dockerhub-tag-preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image, regex: draft.tag_regex || "" }),
})
if (!cancelled) setDockerTagPreview(result)
} catch (e: any) {
if (!cancelled) setDockerTagPreviewError(e?.message || t("vmLxc.appEditor.dockerTagPreviewFailed"))
} finally {
if (!cancelled) setDockerTagPreviewLoading(false)
}
}, 500)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [editing?.draft.docker_image, editing?.draft.tag_regex, editing?.draft.upstream_type, t])
// Turn a raw backend error string ("network error: The read operation
// timed out", etc.) into a localized message. Upstream check errors
// are surfaced verbatim by `lxc_apps.py:_fetch_upstream()`, and the
@@ -329,18 +475,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
return msg
}
// Fetch the picker catalog once per panel mount. Best-effort — if
// the API is unreachable, the picker just stays empty and users
// type the app name manually (same as before this feature).
const editorOpen = !!editing
// The picker catalog is only needed after the user opens the editor.
useEffect(() => {
if (!editorOpen || catalog.length > 0) return
let cancelled = false
fetchApi("/api/apps/catalog")
fetchApi<CatalogEntry[]>("/api/apps/catalog")
.then((data: CatalogEntry[]) => {
if (!cancelled && Array.isArray(data)) setCatalog(data)
})
.catch(() => { /* non-fatal */ })
return () => { cancelled = true }
}, [])
}, [editorOpen, catalog.length])
// Derived state — computed here BEFORE any conditional early
// return so React sees the same hook order on every render.
@@ -396,6 +543,47 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// different-app panel. Not affected by registration state.
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
const searchInstalledApplications = async () => {
const before = new Set(visibleDetected.map((item) => item.slug))
setSearchingApplications(true)
setDetectionNotice(null)
setError(null)
try {
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
method: "POST",
})
setSuggestions(result)
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
const detected = new Set<string>()
if (result.helper_slug) detected.add(result.helper_slug)
for (const item of result.extras || []) detected.add(item.slug)
const visible = [...detected].filter(
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
)
const newCount = visible.filter((slug) => !before.has(slug)).length
if (newCount === 1) {
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
} else if (newCount > 1) {
setDetectionNotice({
found: true,
text: t("vmLxc.appEditor.newApplicationsDetected", { count: newCount }),
})
} else {
setDetectionNotice({
found: false,
text: t(visible.length === 0 && apps.length === 0
? "vmLxc.appEditor.noApplicationsDetected"
: "vmLxc.appEditor.noNewApplicationsDetected"),
})
}
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
} finally {
setSearchingApplications(false)
}
}
// "Register a different app" behavior: if there are hidden slugs,
// surface them first (with Restore) so the user can bring one back
// instead of typing everything by hand. If nothing to restore, go
@@ -437,6 +625,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
health_path: existing.health_path || "",
logo_url: existing.logo_url || "",
helper_slug: existing.helper_slug || "",
update_command: existing.update_command || "",
hide_no_updater_notice: existing.hide_no_updater_notice === true,
notifications_enabled: existing.notifications_enabled !== false,
exclude_from_badge: existing.exclude_from_badge === true,
}
@@ -461,7 +651,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
seed.name = p.name
seed.logo_url = p.logo_url || ""
seed.helper_slug = p.slug
if (p.default_ports?.length) {
// Docker endpoints come from the real published host-port mappings
// listed under Web links. Do not pre-save a catalog default such as
// 9000; the user explicitly chooses which workload links to add.
if (p.slug !== "docker" && p.default_ports?.length) {
seed.ports = p.default_ports.map((port) => ({
port,
scheme: defaultSchemeFor(port),
@@ -477,6 +670,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
file_path: t.file_path || "",
file_regex: t.file_regex || "",
binary_path: t.binary_path || "",
binary_args: t.binary_args ? [...t.binary_args] : [],
python_path: t.python_path || "",
distribution: t.distribution || "",
container_name: t.container_name || "",
label: t.label || "",
command_argv: t.command_argv ? [...t.command_argv] : [],
installed_version: t.installed_version || "",
installed_regex: t.installed_regex || "",
upstream_type: (t as any).upstream_type || (t.repo ? "github" : ""),
repo: t.repo || "",
github_source: t.github_source || "releases",
@@ -497,11 +698,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
}
}
setEditing({ appId: existing?.id || null, draft: seed })
setDetectorTest(null)
setError(null)
}, [suggestions, vmid, sidecar])
const closeEditor = () => {
setEditing(null)
setDetectorTest(null)
setError(null)
}
@@ -521,7 +724,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
})
if ((r as any).error) throw new Error((r as any).error)
setSidecar(r)
invalidateLxcApps(vmid)
setLxcAppsCached(vmid, r, suggestions)
setEditing(null)
onChange?.()
} catch (e: any) {
@@ -531,6 +734,25 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
}
}
const testDetector = async () => {
if (!editing?.draft.installed_via) return
setTestingDetector(true)
setDetectorTest(null)
setError(null)
try {
const result: DetectorTestResult = await fetchApi(`/api/vms/${vmid}/apps/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editing.draft),
})
setDetectorTest(result)
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.detectorTestFailed"))
} finally {
setTestingDetector(false)
}
}
const checkOne = async (appId: string) => {
setBusyAppId(appId)
setError(null)
@@ -539,7 +761,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
method: "POST",
})
setSidecar(r)
invalidateLxcApps(vmid)
setLxcAppsCached(vmid, r, suggestions)
onChange?.()
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.checkFailed"))
@@ -565,7 +787,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
body: JSON.stringify(payload),
})
setSidecar(r)
invalidateLxcApps(vmid)
setLxcAppsCached(vmid, r, suggestions)
onChange?.()
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
@@ -579,10 +801,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
setBusyAppId(appId)
setError(null)
try {
await fetchApi(`/api/vms/${vmid}/apps/${appId}`, { method: "DELETE" })
// Reload from server so the empty state re-fetches suggestions
invalidateLxcApps(vmid)
await load()
const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/${appId}`, { method: "DELETE" })
setSidecar(r)
setLxcAppsCached(vmid, r, suggestions)
onChange?.()
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.deleteFailed"))
@@ -608,7 +829,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
body: JSON.stringify({ slug, dismissed: true }),
})
setSidecar(r)
invalidateLxcApps(vmid)
setLxcAppsCached(vmid, r, suggestions)
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.dismissFailed"))
await load() // resync on failure
@@ -631,7 +852,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
body: JSON.stringify({ slug, dismissed: false }),
})
setSidecar(r)
invalidateLxcApps(vmid)
setLxcAppsCached(vmid, r, suggestions)
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.restoreFailed"))
await load()
@@ -666,7 +887,6 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const repo = isSecureGateway ? "tailscale/tailscale" : ""
const methodLine = isSecureGateway ? `apk · tailscale · ${t("vmLxc.appEditor.managedStatus")}` : t("vmLxc.appEditor.managedStatus")
const hasUpdate = managed.update_available === true
const upToDate = managed.update_available === false && !!managed.installed_version
const showVersions = !!(managed.installed_version || managed.latest_version || repo)
return (
@@ -756,16 +976,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)}
{showVersions && (
<div className={"mt-3 grid gap-3 " + (repo ? "grid-cols-2" : "grid-cols-1")}>
{showVersions && (managed.installed_version || repo) && (
<div className={"mt-3 grid gap-3 " + (managed.installed_version && repo ? "grid-cols-2" : "grid-cols-1")}>
{managed.installed_version && (
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.installedStatus")}</div>
<div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2">
<div className="text-lg font-semibold font-mono text-foreground">
{managed.installed_version}
{upToDate && (
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.appEditor.upToDateBadge")} />
)}
</div>
</div>
)}
@@ -818,8 +1035,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const draft = editing.draft
const method = draft.installed_via || ""
const isPackaged = method === "dpkg" || method === "apk"
const setField = (patch: Partial<AppConfig>) =>
const setField = (patch: Partial<AppConfig>) => {
setDetectorTest(null)
setEditing({ ...editing, draft: { ...draft, ...patch } })
}
// Editing the Name auto-fills the Package field on packaged
// methods when it's still empty. Rationale: 90% of the time the
// dpkg/apk package name mirrors the friendly app name (jellyfin,
@@ -853,10 +1072,37 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
setField({ ports: [...draft.ports, { port, description: "", scheme }] })
}
}
const addDockerWebLink = (link: DockerWebLinkSuggestion) => {
const entry: PortEntry = {
port: link.host_port,
description: link.service_name,
scheme: link.scheme,
web_path: link.web_path || "/",
logo_url: link.logo_url || "",
}
const last = draft.ports[draft.ports.length - 1]
if (last && last.port === "" && !last.description) {
const ports = [...draft.ports]
ports[ports.length - 1] = entry
setField({ ports })
} else {
setField({ ports: [...draft.ports, entry] })
}
}
const removePort = (i: number) =>
setField({ ports: draft.ports.filter((_, idx) => idx !== i) })
const usedPorts = new Set(draft.ports.map((p) => p.port))
const suggestable = (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
const isDockerDraft = draft.helper_slug === "docker" ||
(draft.installed_via === "binary" && draft.binary_path?.endsWith("/docker"))
const suggestableDockerLinks = isDockerDraft
? (suggestions?.docker_web_links || []).filter((link) => !usedPorts.has(link.host_port))
: []
// A Docker registration uses structured container → published-port
// suggestions below. Suppress the generic ss/netstat chips in that case
// so the same endpoint is not presented twice without its workload name.
const suggestable = isDockerDraft
? []
: (suggestions?.port_suggestions || []).filter((p) => !usedPorts.has(p))
return (
<div className="space-y-4">
@@ -908,13 +1154,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
onClick={async () => {
setPickerOpen(false)
try {
const detail: CatalogDetail = await fetchApi(`/api/apps/catalog/${c.slug}`)
const detail: CatalogDetail = await fetchApi(`/api/apps/catalog/${c.slug}?vmid=${vmid}`)
// Seed the entire form from the picker detail.
const patch: Partial<AppConfig> = {
name: detail.name,
helper_slug: detail.slug,
logo_url: detail.logo_url || "",
ports: detail.default_ports?.length
ports: detail.slug !== "docker" && detail.default_ports?.length
? detail.default_ports.map((p) => ({
port: p,
scheme: defaultSchemeFor(p),
@@ -1002,6 +1248,53 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</Button>
</div>
{suggestableDockerLinks.length > 0 && (
<div className="mb-3 space-y-2">
<div>
<div className="text-xs font-medium text-foreground">
{t("vmLxc.appEditor.dockerPublishedServicesTitle")}
</div>
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed max-w-2xl">
{t("vmLxc.appEditor.dockerPublishedServicesHelp")}
</p>
</div>
<div className="divide-y divide-border/50 border-y border-border/50">
{suggestableDockerLinks.map((link) => (
<div
key={`${link.container_name}:${link.host_port}`}
className="flex flex-col sm:flex-row sm:items-center gap-2 py-2"
>
{link.logo_url && (
<ThemeAwareLogo
src={link.logo_url}
className="h-7 w-7 rounded object-contain flex-shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<div className="text-sm text-foreground truncate">{link.service_name}</div>
<div className="text-[10px] text-muted-foreground truncate" title={link.image}>
{link.image} · {t("vmLxc.appEditor.dockerPublishedPort", {
containerPort: link.container_port,
hostPort: link.host_port,
})}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addDockerWebLink(link)}
className="h-8 flex-shrink-0"
>
<PlusCircle className="h-3.5 w-3.5 mr-1" />
{t("vmLxc.appEditor.dockerPublishedAdd")}
</Button>
</div>
))}
</div>
</div>
)}
{/* Detected chips FIRST one-click add. Only shown when
there are chips left to suggest, so empty states stay
clean. Click on a chip: fills the current empty row
@@ -1024,7 +1317,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)}
{draft.ports.length === 0 && suggestable.length === 0 && (
{draft.ports.length === 0 && suggestable.length === 0 && suggestableDockerLinks.length === 0 && (
<div className="text-xs text-muted-foreground italic">
{t("vmLxc.appEditor.noWebPorts")}
</div>
@@ -1470,9 +1763,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
maxLength={255}
/>
<div className="text-[10px] text-muted-foreground mt-1">
<code className="text-foreground/70">owner/name</code> or bare
name for official images. ProxMenux picks the highest semver tag
matching the filter below.
{t("vmLxc.appEditor.dockerVersionedTagsHelp")}
</div>
</div>
)}
@@ -1489,13 +1780,82 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
placeholder={t("vmLxc.appEditor.tagRegexPlaceholder")}
className="font-mono text-xs"
/>
{upstreamType === "docker_hub" && (
<div className="flex flex-wrap gap-1.5 mt-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px]"
onClick={() => setField({ tag_regex: "^v?(\\d+\\.\\d+\\.\\d+)$" })}
>
{t("vmLxc.appEditor.dockerPresetSemver")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px]"
onClick={() => setField({ tag_regex: "^v?(\\d+\\.\\d+\\.\\d+(?:[-+._][0-9A-Za-z.-]+)?)$" })}
>
{t("vmLxc.appEditor.dockerPresetSemverSuffix")}
</Button>
</div>
)}
<div className="text-[10px] text-muted-foreground mt-1">
{upstreamType === "github" && "Extracts the version from the release tag name."}
{upstreamType === "http_json" && "Optional — extract a substring from the endpoint's value."}
{upstreamType === "docker_hub" && "Filter which tags qualify (e.g. only semver). Applied before picking the highest."}
{upstreamType === "docker_hub" && t("vmLxc.appEditor.dockerTagFilterHelp")}
</div>
</div>
)}
{upstreamType === "docker_hub" && draft.docker_image?.trim() && (
<div className="rounded-md border border-border/70 bg-background/50 p-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-xs font-medium text-foreground">
{t("vmLxc.appEditor.dockerTagPreviewLabel")}
</div>
{dockerTagPreview && (
<span className="text-[10px] text-muted-foreground">
{t("vmLxc.appEditor.dockerTagPreviewCount", {
matched: dockerTagPreview.matched_count,
scanned: dockerTagPreview.scanned_count,
})}
</span>
)}
</div>
{dockerTagPreviewLoading ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("vmLxc.appEditor.dockerTagPreviewLoading")}
</div>
) : dockerTagPreviewError ? (
<div className="text-xs text-amber-300">{dockerTagPreviewError}</div>
) : dockerTagPreview?.tags.length ? (
<div className="flex flex-wrap gap-1.5">
{dockerTagPreview.tags.map((entry) => (
<Badge
key={entry.tag}
variant="outline"
className={entry.moving ? "border-amber-500/40 text-amber-300" : "font-mono"}
>
{entry.tag}{entry.moving ? ` · ${t("vmLxc.appEditor.dockerMovingTag")}` : ""}
</Badge>
))}
</div>
) : (
<div className="text-xs text-muted-foreground">
{t("vmLxc.appEditor.dockerTagPreviewEmpty")}
</div>
)}
{dockerTagPreview?.tags.some((entry) => entry.moving) && (
<div className="mt-2 text-[10px] text-amber-300 leading-relaxed">
{t("vmLxc.appEditor.dockerMovingTagHelp")}
</div>
)}
</div>
)}
</>
)
})()}
@@ -1541,6 +1901,47 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)}
{detectorTest && (
<div className="rounded-md border border-border/70 bg-background/60 p-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium text-foreground">
{t("vmLxc.appEditor.detectorTestTitle")}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className={"rounded-md border p-2.5 " + (detectorTest.installed.version ? "border-emerald-500/30 bg-emerald-500/5" : "border-amber-500/30 bg-amber-500/5")}>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
{t("vmLxc.appEditor.installedStatus")}
</div>
{detectorTest.installed.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
{detectorTest.installed.method || "—"}
{detectorTest.installed.effective_regex ? ` · ${detectorTest.installed.effective_regex}` : ""}
</div>
</div>
<div className={"rounded-md border p-2.5 " + (!detectorTest.upstream.configured || detectorTest.upstream.version ? "border-emerald-500/30 bg-emerald-500/5" : "border-amber-500/30 bg-amber-500/5")}>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
{t("vmLxc.appEditor.latestUpstream")}
</div>
{!detectorTest.upstream.configured ? (
<div className="text-xs text-muted-foreground">{t("vmLxc.appEditor.detectorTestNoUpstream")}</div>
) : detectorTest.upstream.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
{detectorTest.upstream.type && (
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
)}
</div>
</div>
</div>
)}
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
@@ -1549,10 +1950,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)}
<div className="flex flex-wrap gap-2 justify-end pt-2">
<Button variant="ghost" onClick={closeEditor} disabled={saving}>{t("vmLxc.appEditor.cancelButton")}</Button>
{method && (
<Button
type="button"
variant="outline"
onClick={testDetector}
disabled={saving || testingDetector || !draft.name.trim()}
className="sm:mr-auto"
>
{testingDetector ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <ShieldCheck className="h-4 w-4 mr-1.5" />}
{testingDetector ? t("vmLxc.appEditor.testingDetectorButton") : t("vmLxc.appEditor.testDetectorButton")}
</Button>
)}
<Button variant="ghost" onClick={closeEditor} disabled={saving || testingDetector}>{t("vmLxc.appEditor.cancelButton")}</Button>
<Button
onClick={save}
disabled={saving || !draft.name.trim()}
disabled={saving || testingDetector || !draft.name.trim()}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
{saving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Save className="h-4 w-4 mr-1.5" />}
@@ -1573,11 +1986,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{d.logo_url && (
<img
<ThemeAwareLogo
src={d.logo_url}
alt=""
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
<div className="min-w-0">
@@ -1613,16 +2024,23 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{d.logo_url && (
<img
<ThemeAwareLogo
src={d.logo_url}
alt=""
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">{d.name}</div>
<div className="text-xs text-emerald-400/90">{t("vmLxc.appEditor.detectedInContainer")}</div>
<div className="text-xs text-emerald-400/90">
{d.tracking_suggestion?.detector_verified
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
: t("vmLxc.appEditor.detectedInContainer")}
</div>
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
<div className="text-[10px] text-amber-400/90 mt-0.5">
{t("vmLxc.appEditor.legacyDetectorUsed")}
</div>
)}
</div>
</div>
<div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto">
@@ -1716,11 +2134,25 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{visibleDetected.map(renderDetectionChip)}
</div>
)}
<div className="pt-1 flex justify-center">
<Button onClick={openBrowseOrEditor} variant={visibleDetected.length > 0 ? "outline" : "default"}
className={visibleDetected.length > 0 ? "" : "bg-blue-500 hover:bg-blue-600 text-white"}>
<div className="pt-1 flex flex-wrap justify-center gap-2">
<Button
onClick={searchInstalledApplications}
disabled={searchingApplications}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
{searchingApplications
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
: <Search className="h-4 w-4 mr-1.5" />}
{searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
</Button>
<Button
onClick={openBrowseOrEditor}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
<PlusCircle className="h-4 w-4 mr-1.5" />
{visibleDetected.length > 0 ? t("vmLxc.appEditor.registerDifferent") : t("vmLxc.appEditor.registerApplication")}
{t("vmLxc.appEditor.registerApplication")}
{hiddenDetections.length > 0 && (
<span className="ml-2 text-[10px] opacity-70">
· {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })}
@@ -1728,6 +2160,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)}
</Button>
</div>
{detectionNotice && (
<p className={`text-xs text-center ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
{detectionNotice.text}
</p>
)}
</CardContent>
</Card>
)}
@@ -1738,19 +2175,15 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// Version tracking is on when installed_via is set. Without a
// method the app is register-only — no cards, no warnings.
const tracking = !!app.installed_via
const hasUpdate = tracking && st?.update_available === true
const upToDate = tracking && st?.update_available === false && !!st?.installed_version
return (
<Card key={app.id} className="border border-border bg-card/50">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{app.logo_url && (
<img
<ThemeAwareLogo
src={app.logo_url}
alt=""
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
<div className="min-w-0 flex-1">
@@ -1801,30 +2234,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)}
</div>
{/* Version panels always same-line (grid-cols-2), on
every viewport when ANY upstream source is configured
(github repo, http_json endpoint, or docker_hub
image). The signal that an update exists is the
LATEST UPSTREAM number turning purple + a small
ArrowUpCircle next to it, matching the project-wide
update button convention. Absence of purple = up to
date; no colored banners, no green/orange noise.
Labels stay muted so numbers keep visual priority.
When no upstream is configured, the LATEST panel is
omitted entirely and INSTALLED takes the full width. */}
{(() => {
const hasUpstream = !!(app.repo || app.upstream_type)
const hasUpdate = st?.update_available === true
if (!tracking || !(st?.installed_version || hasUpstream)) return null
return (
<div className={"mb-3 grid gap-3 " + (hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
<div className={"mb-3 grid gap-3 " + (st?.installed_version && hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
{st?.installed_version && (
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.installedStatus")}</div>
<div className="text-lg font-semibold font-mono text-foreground flex items-center gap-2">
<div className="text-lg font-semibold font-mono text-foreground">
{st.installed_version}
{upToDate && (
<CheckCircle2 className="h-5 w-5 text-green-500 flex-shrink-0" aria-label={t("vmLxc.appEditor.upToDateBadge")} />
)}
</div>
</div>
)}
@@ -1867,11 +2287,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
return (
<div key={p.port} className="flex items-start gap-3 min-w-0">
{p.logo_url && (
<img
<ThemeAwareLogo
src={p.logo_url}
alt=""
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none" }}
/>
)}
<div className="min-w-0 flex flex-col">
@@ -1896,8 +2314,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{/* Footer with per-card actions only rendered in the
global edit mode (toggled from the "Edit" button next
to Add another application). View mode keeps cards
chrome-free; Check is still reachable via the
hover-reveal icon on the LATEST panel. Buttons match
chrome-free; Check remains available in edit mode.
Buttons match
the Settings-page section style (h-8, outline,
small icon + label) for visual consistency across
the app. */}
@@ -1984,7 +2402,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
detections, so the user gets one-click Restore before hand-
typing a custom app. */}
{apps.length > 0 && (
<div className="flex justify-end items-center gap-2">
<div className="flex flex-wrap justify-end items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={searchInstalledApplications}
disabled={searchingApplications || editMode}
>
{searchingApplications
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
: <Search className="h-4 w-4 mr-1.5" />}
{searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
</Button>
<Button
variant="outline"
size="sm"
@@ -2019,6 +2450,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)}
{apps.length > 0 && detectionNotice && (
<p className={`text-xs text-right ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
{detectionNotice.text}
</p>
)}
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
File diff suppressed because it is too large Load Diff
+39 -8
View File
@@ -1,5 +1,5 @@
// Shared cross-component cache for the LXC App-tab payload
// (registered sidecar + auto-detected suggestions). Used by both
// (registered sidecar + cached detection suggestions). Used by both
// virtual-machines.tsx (which prefetches on modal open and on hover)
// and lxc-app-panel.tsx (which reads the cache first and only fetches
// if empty). The in-flight promise map dedups concurrent requests: if
@@ -16,14 +16,37 @@ export type LxcAppsBundle = {
const dataCache = new Map<number, LxcAppsBundle>()
const inFlight = new Map<number, Promise<LxcAppsBundle | null>>()
const cacheRevision = new Map<number, number>()
export function getLxcAppsCached(vmid: number): LxcAppsBundle | undefined {
return dataCache.get(vmid)
}
// Write-through after a successful App/Updates mutation. The API returns the
// complete sidecar, so evicting this entry would throw away newer data and
// make the App tab flash "Loading applications..." on its next mount. Keep
// the already-fetched suggestions unless the caller explicitly replaces them.
export function setLxcAppsCached(
vmid: number,
sidecar: any,
suggestions?: any | null,
): LxcAppsBundle {
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
const current = dataCache.get(vmid)
const bundle: LxcAppsBundle = {
sidecar,
suggestions: suggestions === undefined
? (current?.suggestions ?? null)
: suggestions,
}
dataCache.set(vmid, bundle)
return bundle
}
export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
const existing = inFlight.get(vmid)
if (existing) return existing
const startedRevision = cacheRevision.get(vmid) || 0
const p = Promise.all([
fetchApi(`/api/vms/${vmid}/apps`).catch(() => null) as Promise<any>,
fetchApi(`/api/vms/${vmid}/apps/suggestions`).catch(() => null) as Promise<any>,
@@ -31,6 +54,11 @@ export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
.then(([sc, sug]) => {
if (!sc) return null
const bundle: LxcAppsBundle = { sidecar: sc, suggestions: sug }
// A successful write may have completed while these GETs were in
// flight. Never let that older response overwrite the mutation result.
if ((cacheRevision.get(vmid) || 0) !== startedRevision) {
return dataCache.get(vmid) ?? null
}
dataCache.set(vmid, bundle)
return bundle
})
@@ -42,17 +70,20 @@ export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
}
export function invalidateLxcApps(vmid: number): void {
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
dataCache.delete(vmid)
}
// Seed the cache with a sidecar payload from the bulk modal-cache
// endpoint. Only the sidecar side is populated — suggestions still
// resolve lazily when the App panel actually mounts (the bulk
// endpoint intentionally excludes them since most guests don't
// need the auto-detected chips and the payload would balloon).
export function seedLxcAppsCache(vmid: number, sidecar: any): void {
// Seed the cache from the bulk modal-cache endpoint. Both registered
// apps and startup detection suggestions are already in memory, so
// opening the App tab never starts a discovery scan.
export function seedLxcAppsCache(
vmid: number,
sidecar: any,
suggestions?: any | null,
): void {
if (!sidecar) return
const existing = dataCache.get(vmid)
if (existing) return // per-panel fetch already ran, don't overwrite
dataCache.set(vmid, { sidecar, suggestions: null })
dataCache.set(vmid, { sidecar, suggestions: suggestions ?? null })
}
+107 -8
View File
@@ -1163,7 +1163,15 @@
"targetBoth": "Betriebssystem + Anwendung",
"lastRun": "Letzte Ausführung: {date}",
"runSuccess": "✓ Erfolg",
"runPartial": "teilweise abgeschlossen",
"runFailed": "✗ fehlgeschlagen",
"runDeferred": "zur Sicherheit zurückgestellt",
"runSkipped": "nichts ausstehend",
"releaseDelaySummary": "{days} Tag(e) Freigabesperre",
"releaseDelayLabel": "Nach neuer App-Version warten",
"releaseDelayNone": "Keine Wartezeit",
"releaseDelayDays": "{days} Tag(e) warten",
"releaseDelayHelp": "Gilt nur für ausgewählte Apps mit Versionsüberwachung. Deren ausstehende Versionen müssen dieses Alter erreicht haben; Apps ohne Versionsüberwachung führen ihren Updater bei jedem Zeitplan aus.",
"notScheduled": "Nicht geplant",
"frequency": "Frequenz",
"cronExpression": "Cron-Ausdruck",
@@ -1172,6 +1180,11 @@
"targetOptionOs": "Nur Betriebssystempakete",
"targetOptionApp": "Nur Bewerbung",
"targetOptionBoth": "Betriebssystem + Anwendung",
"dockerComposeTarget": "Docker Compose · {project}",
"dockerContainerTarget": "Docker-Container · {container}",
"dockerEngineTarget": "Docker Engine",
"noTargets": "Keine Ziele ausgewählt",
"selectAtLeastOne": "Wählen Sie mindestens ein Aktualisierungsziel aus.",
"deleteButton": "Zeitplan löschen",
"deleteConfirm": "Geplante Updates für diesen Container entfernen? Die Standardeinstellungen (Sicherung + Neustart) werden beibehalten."
},
@@ -1217,7 +1230,7 @@
"osPlusApp": "Betriebssystem + {appName}-Updates",
"osPlusApps": "Betriebssystem- und Apps-Updates",
"noUpdateMethodTitle": "Keine Aktualisierungsmethode verfügbar",
"noUpdateMethodBody": "Keine Aktualisierungsmethode verfügbar. Aktualisieren Sie über die App selbst oder legen Sie einen benutzerdefinierten Befehl auf der Registerkarte „App“ fest.",
"noUpdateMethodBody": "Für diese Anwendung wurde keine Aktualisierungsmethode erkannt. Fügen Sie einen benutzerdefinierten Aktualisierungsbefehl hinzu.",
"ociImmutableTitle": "OCI-Image-Container",
"ociImmutableBody": "Betriebssystempakete werden zum Zeitpunkt der Image-Erstellung integriert und können nicht direkt aktualisiert werden. Wenden Sie Updates manuell an oder installieren Sie sie mit einem neueren Image neu.",
"hideNoticeButton": "Diesen Hinweis für diese App ausblenden",
@@ -1230,6 +1243,7 @@
"applicationDefaultName": "Anwendung",
"installedLabel": "installiert",
"upToDateAtLabel": "Aktuell unter",
"versionLabel": "Version {version}",
"versionTrackingPending": "Versionsverfolgung ausstehend bei der nächsten geplanten Prüfung werden die installierte und die verfügbare Version angezeigt.",
"customCommandTitle": "Benutzerdefinierter Update-Befehl",
"customCommandBody": "Führen Sie einen benutzerdefinierten Shell-Befehl aus, um diese App im Container zu aktualisieren.",
@@ -1238,6 +1252,7 @@
"hideNoticeAction": "Diesen Hinweis für {appName} ausblenden",
"detectedByPrefix": "Erkannt von",
"editApp": "Bearbeiten",
"configureUpdater": "Konfigurieren",
"alsoDetectedTitle": "Außerdem in diesem Container entdeckt:",
"detectedInline": "· erkannt von {method}",
"customCommandLabel": "Benutzerdefinierter Update-Befehl",
@@ -1245,9 +1260,9 @@
"removeButton": "Entfernen",
"cancelButton": "Stornieren",
"saveButton": "Speichern",
"editCommandButton": "Befehl bearbeiten",
"wireUpCommandButton": "Fügen Sie einen benutzerdefinierten Aktualisierungsbefehl hinzu",
"versionTrackingPendingShort": "Versionsverfolgung steht aus.",
"versionTrackingNotConfigured": "Die Versionsüberwachung ist nicht konfiguriert.",
"managedByOsPackages": "Diese Anwendung wird über die Aktion für Betriebssystempakete aktualisiert.",
"noMethodBody": "Keine Aktualisierungsmethode verfügbar. Aktualisieren Sie über die App selbst oder verknüpfen Sie einen benutzerdefinierten Befehl, den ProxMenux im Container ausführt.",
"noMethodHideBody": "Für diese App ist keine Update-Methode registriert. Legen Sie eine auf der Registerkarte „App“ fest oder schließen Sie diesen Hinweis.",
"hideForApp": "Hinweis für {appName} ausblenden",
@@ -1266,15 +1281,59 @@
"noManagedUpdateInfo": "Noch keine Update-Informationen prüfen Sie unter Sicherheit → Secure Gateway.",
"ociTitle": "OCI-Image-Container",
"ociBody": "Dieser Container wurde aus einem OCI-Image (Docker) erstellt. Die Updateverwaltung für OCI-Container wird mit der kommenden OCI-Installationsfunktion geliefert Updates erstellen den Container anhand eines neueren Image-Tags neu, anstatt Pakete darin zu patchen.",
"helperNotUpdateable": "Die Community-Scripts-Registrierung markiert diese App als nicht aktualisierbar.",
"helperDetectedTitle": "Es wurde ein Hilfsskript-Updater erkannt",
"helperDetectedBody": "Die manuelle Anwendung ist am sichersten.",
"dockerImagesTitle": "Docker-Images",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Images",
"dockerImagesReadOnly": "Image-Tags werden anhand des Registry-Digests verglichen. Compose-Dienste können aus ihrem deklarierten Projekt geladen und neu erstellt werden.",
"dockerEngineManagedByOs": "Docker Engine {version} wird über Pakete verwaltet und durch die obige Betriebssystem-Paketaktion aktualisiert.",
"dockerEngineSubheading": "Engine",
"dockerEngineDetected": "Docker Engine {version} erkannt",
"dockerEngineUpdateHelp": "Aktualisiert nur die installierten Docker-Pakete und erforderlichen Abhängigkeiten. Andere Betriebssystempakete und Container bleiben unverändert.",
"updateDockerEngineOnly": "Docker Engine aktualisieren",
"adguardWebUpdateOnly": "Diese AdGuard-Home-Installation wird über die eigene Weboberfläche aktualisiert.",
"openAdguard": "AdGuard Home öffnen",
"updateDockerImage": "Image aktualisieren",
"recreateStandaloneContainer": "{container} aktualisieren",
"standaloneRecreatePending": "Eigenständige Container benötigen eine geschützte Neuerstellung: {names}",
"noDockerImages": "Docker ist installiert, aber es wurden keine getaggten Images gefunden.",
"dockerInventoryStarting": "Warten, bis Docker vollständig gestartet ist…",
"dockerInventoryUnavailable": "Das Docker-Inventar konnte nicht gelesen werden. Prüfen Sie es erneut, sobald Docker verfügbar ist.",
"usedByContainers": "Verwendet von: {names}",
"imageUpdateAvailable": "Neues Image verfügbar",
"imageUpToDate": "Aktuell",
"imageInstalledTag": "installierter Tag",
"imageDigestUnknown": "Digest nicht verfügbar",
"dockerPendingSummary": "{count} Docker-Image-Update(s) erkannt. Aktualisiere sie mit dem zugehörigen Docker- oder Compose-Workflow.",
"postApplyChecking": "Aktualisierungsergebnis wird überprüft…",
"postApplyAllOk": "{count} Paket(e) erfolgreich angewendet nichts ausstehend.",
"postApplyNothingPending": "Nichts ausstehend alles ist auf dem neuesten Stand.",
"postApplyPartial": "{pending} Paket(e) stehen nach der Ausführung noch aus.",
"postApplyPartialSubline": "{applied} angewendet.Einige Aktualisierungen wurden nicht abgeschlossen sehen Sie sich die Terminalausgabe oben an."
},
"bulkUpdate": {
"title": "Sammelaktualisierung",
"description": "Konfiguriert eine Aktion für das Betriebssystem und die ausgewählten Aktualisierungsmethoden dieses Containers.",
"loading": "Konfiguration wird geladen…",
"notConfigured": "Nicht konfiguriert.",
"configure": "Konfigurieren",
"edit": "Bearbeiten",
"apply": "Aktualisierungen anwenden",
"osTarget": "Betriebssystem",
"osRequired": "Für eine Sammelaktualisierung erforderlich.",
"includesDependencies": "Enthält auch deklarierte Abhängigkeiten: {names}",
"noMethod": "Keine ausführbare Aktualisierungsmethode verfügbar.",
"staleTarget": "Diese Auswahl ist nicht mehr verfügbar. Entfernen Sie sie vor dem Speichern.",
"dockerInventoryPending": "Warten auf das Docker-Image-Inventar…",
"missingDockerTarget": "Docker-Image nicht verfügbar",
"selectAtLeastOne": "Wählen Sie zusätzlich zum Betriebssystem mindestens ein Aktualisierungsziel.",
"cancel": "Abbrechen",
"save": "Speichern",
"delete": "Konfiguration entfernen",
"deleteConfirm": "Diese Sammelaktualisierungskonfiguration entfernen?",
"saveFailed": "Die Sammelaktualisierungskonfiguration konnte nicht gespeichert werden.",
"deleteFailed": "Die Sammelaktualisierungskonfiguration konnte nicht entfernt werden.",
"planFailed": "Die Sammelaktualisierung konnte nicht vorbereitet werden. Prüfen Sie die nicht verfügbaren Methoden."
},
"appEditor": {
"closePanel": "Panel schließen",
"cancelButton": "Stornieren",
@@ -1284,6 +1343,17 @@
"hiddenBadge": "Derzeit ausgeblendet wird wieder in der Erkennungsliste angezeigt",
"registerDifferent": "Registrieren Sie eine andere App",
"detectedInContainer": "Auf diesem Container erkannt",
"versionDetected": "Version {version} in diesem Container erkannt",
"legacyDetectorUsed": "Mit einem alten Kompatibilitäts-Fallback erkannt",
"dockerImagesTitle": "Docker-Image-Updates",
"dockerImagesHelp": "Prüft jedes lokale Image getrennt von der Docker-Engine anhand unveränderlicher Registry-Digests.",
"refreshDockerImages": "Jetzt prüfen",
"noDockerImages": "Keine getaggten Docker-Images gefunden",
"usedByContainers": "Verwendet von: {names}",
"imageUpdateAvailable": "Neues Image verfügbar",
"imageUpToDate": "Aktuell",
"imageDigestUnknown": "Digest nicht verfügbar",
"dockerReadOnlyNote": "Nur-Lese-Prüfung: Es wird kein Image geladen und kein Container neu gestartet.",
"installedManaged": "Installiert und verwaltet von ProxMenux",
"nameLabel": "Name",
"installedViaLabel": "Installiert über",
@@ -1340,6 +1410,17 @@
"jsonPathPlaceholder": "z. B. data.version oder releases[0].tag_name",
"dockerImageLabel": "Docker Hub-Bild",
"dockerImagePlaceholder": "z. B. linuxserver/plex oder nginx",
"dockerVersionedTagsHelp": "Nur für versionierte Tags. Für latest, stable oder lts die Docker-Image-Updates mit Digest-Vergleich verwenden.",
"dockerPresetSemver": "Nur SemVer",
"dockerPresetSemverSuffix": "SemVer + Suffix",
"dockerTagFilterHelp": "Filtert echte Tags, bevor ProxMenux die höchste Version auswählt.",
"dockerTagPreviewLabel": "Live-Tag-Vorschau",
"dockerTagPreviewCount": "{matched} von {scanned} passend",
"dockerTagPreviewLoading": "Docker Hub wird geprüft…",
"dockerTagPreviewFailed": "Docker-Hub-Tags konnten nicht angezeigt werden",
"dockerTagPreviewEmpty": "Keine echten Tags entsprechen diesem Filter.",
"dockerMovingTag": "beweglicher Tag",
"dockerMovingTagHelp": "Bewegliche Tags enthalten keine Version. Stattdessen über den Digest bei Docker-Image-Updates verfolgen.",
"tagRegexLabel": "Tag-Regex (mit Capture-Gruppe)",
"tagRegexPlaceholder": "z.B. v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1372,7 +1453,14 @@
"registerCustom": "Registrieren Sie eine benutzerdefinierte App",
"noAppsTitle": "Keine Bewerbungen registriert",
"noAppsBody": "Registrieren Sie die Anwendungen in diesem Container, um Weblinks und optional die Verfolgung verfügbarer Versionen mit Benachrichtigungen zu neuen Releases zu erhalten.",
"registerApplication": "Bewerbung registrieren",
"registerApplication": "Anwendung registrieren",
"searchApplications": "Apps suchen",
"searchingApplications": "Apps werden gesucht…",
"noApplicationsDetected": "Keine Apps erkannt.",
"noNewApplicationsDetected": "Keine neuen Apps erkannt.",
"oneNewApplicationDetected": "Eine neue App wurde erkannt.",
"newApplicationsDetected": "{count} neue Apps wurden erkannt.",
"detectionFailed": "App-Suche fehlgeschlagen",
"hiddenSuffix": "{count} ausgeblendet",
"confirmHide": "Die Erkennung „{name}“ dauerhaft in diesem Container ausblenden?\n\nUm es später wiederherzustellen, klicken Sie auf „Andere App registrieren“ die ausgeblendete Liste wird dort mit einer Wiederherstellungsoption angezeigt.",
"saveFailed": "Speichern fehlgeschlagen",
@@ -1383,6 +1471,13 @@
"binaryArgsHintGrafana": "Grafana braucht",
"loadFailed": "Die App-Konfiguration konnte nicht geladen werden",
"checkFailed": "Prüfung fehlgeschlagen",
"testDetectorButton": "Detektor testen",
"testingDetectorButton": "Detektor wird getestet…",
"detectorTestTitle": "Ergebnis des Detektortests",
"detectorTestNotSaved": "Nur Entwurf · nicht gespeichert",
"detectorTestNoVersion": "Keine Version erkannt",
"detectorTestNoUpstream": "Keine Upstream-Quelle konfiguriert",
"detectorTestFailed": "Detektortest fehlgeschlagen",
"deleteFailed": "Das Löschen ist fehlgeschlagen",
"dismissFailed": "Die Erkennung konnte nicht verworfen werden",
"restoreFailed": "Die Erkennung konnte nicht wiederhergestellt werden",
@@ -1399,6 +1494,10 @@
"webLinks": "Weblinks",
"addPort": "Port hinzufügen",
"detectedPorts": "Im Container erkannte Ports klicken Sie zum Hinzufügen:",
"dockerPublishedServicesTitle": "Docker-Dienste mit veröffentlichten Ports",
"dockerPublishedServicesHelp": "Wählen Sie nur Ports aus, die eine Weboberfläche bereitstellen. ProxMenux registriert diese Container nicht als eigenständige LXC-Anwendungen.",
"dockerPublishedAdd": "Link hinzufügen",
"dockerPublishedPort": "Container-Port {containerPort} → {hostPort} im LXC",
"noWebPorts": "Keine Web-Ports lauschen. Verwenden Sie Port hinzufügen, wenn Sie dennoch manuell einen Link erstellen möchten.",
"trackUpstream": "Verfügbare Version verfolgen (optional)",
"trackOff": "Aus nur Link",
@@ -1416,7 +1515,7 @@
"checkButton": "Überprüfen",
"editFieldsButton": "Felder bearbeiten",
"alsoDetectedContainer": "Auch auf diesem Container erkannt",
"addAnotherApplication": "Fügen Sie eine weitere Anwendung hinzu",
"addAnotherApplication": "Weitere Anwendung registrieren",
"doneButton": "Erledigt",
"editButton": "Bearbeiten",
"upstreamErrorTimeout": "Netzwerk-Timeout beim Kontaktieren des Upstreams",
+106 -7
View File
@@ -1162,7 +1162,15 @@
"targetBoth": "OS + application",
"lastRun": "Last run: {date}",
"runSuccess": "✓ success",
"runPartial": "completed partially",
"runFailed": "✗ failed",
"runDeferred": "held for safety",
"runSkipped": "nothing pending",
"releaseDelaySummary": "{days}-day release hold",
"releaseDelayLabel": "Wait after a new app release",
"releaseDelayNone": "No waiting period",
"releaseDelayDays": "Wait {days} day(s)",
"releaseDelayHelp": "Applies only to selected apps with version tracking. Their pending releases must be this old; apps without version tracking still run their updater on every scheduled occurrence.",
"notScheduled": "Not scheduled",
"frequency": "Frequency",
"cronExpression": "Cron expression",
@@ -1171,6 +1179,11 @@
"targetOptionOs": "OS packages only",
"targetOptionApp": "Application only",
"targetOptionBoth": "OS + application",
"dockerComposeTarget": "Docker Compose · {project}",
"dockerContainerTarget": "Docker container · {container}",
"dockerEngineTarget": "Docker Engine",
"noTargets": "No targets selected",
"selectAtLeastOne": "Select at least one update target.",
"deleteButton": "Delete schedule",
"deleteConfirm": "Remove the scheduled updates for this container? Apply defaults (backup + restart) are kept."
},
@@ -1216,7 +1229,7 @@
"osPlusApp": "OS + {appName} updates",
"osPlusApps": "OS + Apps updates",
"noUpdateMethodTitle": "No update method available",
"noUpdateMethodBody": "No update method available. Update from the app itself, or set a custom command in the App tab.",
"noUpdateMethodBody": "No update method has been identified for this application. Add a custom update command.",
"ociImmutableTitle": "OCI image container",
"ociImmutableBody": "OS packages are baked in at image build time and cannot be updated in place. Apply updates manually or reinstall with a newer image.",
"hideNoticeButton": "Hide this notice for this app",
@@ -1229,6 +1242,7 @@
"applicationDefaultName": "Application",
"installedLabel": "installed",
"upToDateAtLabel": "Up to date at",
"versionLabel": "version {version}",
"versionTrackingPending": "Version tracking pending — the next scheduled check will populate installed and upstream numbers.",
"customCommandTitle": "Custom update command",
"customCommandBody": "Run a user-defined shell command to update this app inside the container.",
@@ -1237,6 +1251,7 @@
"hideNoticeAction": "Hide this notice for {appName}",
"detectedByPrefix": "Detected by",
"editApp": "Edit",
"configureUpdater": "Configure",
"alsoDetectedTitle": "Also detected in this container:",
"detectedInline": "· detected by {method}",
"customCommandLabel": "Custom update command",
@@ -1244,9 +1259,9 @@
"removeButton": "Remove",
"cancelButton": "Cancel",
"saveButton": "Save",
"editCommandButton": "Edit command",
"wireUpCommandButton": "Add custom update command",
"versionTrackingPendingShort": "Version tracking pending.",
"versionTrackingNotConfigured": "Version tracking is not configured.",
"managedByOsPackages": "This application is updated by the OS packages action.",
"noMethodBody": "No update method available. Update from the app itself, or wire up a custom command that ProxMenux will run inside the container.",
"noMethodHideBody": "This app has no update method registered. Set one from the App tab or dismiss this notice.",
"hideForApp": "Hide notice for {appName}",
@@ -1265,15 +1280,59 @@
"noManagedUpdateInfo": "No update information yet — check from Security → Secure Gateway.",
"ociTitle": "OCI image container",
"ociBody": "This container was created from an OCI (Docker) image. Update management for OCI containers is coming with the upcoming OCI install feature — updates will rebuild the container from a newer image tag rather than patching packages inside.",
"helperNotUpdateable": "The community-scripts registry marks this app as not updateable.",
"helperDetectedTitle": "Detected a helper-scripts updater",
"helperDetectedBody": "Applying manually is safest.",
"dockerImagesTitle": "Docker images",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Images",
"dockerImagesReadOnly": "Image tags are compared by registry digest. Compose services can be pulled and recreated from their declared project.",
"dockerEngineManagedByOs": "Docker Engine {version} is package-managed and is updated by the OS packages action above.",
"dockerEngineSubheading": "Engine",
"dockerEngineDetected": "Docker Engine {version} detected",
"dockerEngineUpdateHelp": "Updates only the installed Docker packages and required dependencies. It does not upgrade other OS packages or recreate containers.",
"updateDockerEngineOnly": "Update Docker Engine",
"adguardWebUpdateOnly": "This AdGuard Home installation is updated from its own web interface.",
"openAdguard": "Open AdGuard Home",
"updateDockerImage": "Update image",
"recreateStandaloneContainer": "Update {container}",
"standaloneRecreatePending": "Standalone container(s) require protected recreation: {names}",
"noDockerImages": "Docker is installed, but no tagged images were found.",
"dockerInventoryStarting": "Waiting for Docker to finish starting…",
"dockerInventoryUnavailable": "The Docker inventory could not be read. Check again when Docker is available.",
"usedByContainers": "Used by: {names}",
"imageUpdateAvailable": "New image available",
"imageUpToDate": "Up to date",
"imageInstalledTag": "installed tag",
"imageDigestUnknown": "Digest unavailable",
"dockerPendingSummary": "{count} Docker image update(s) detected. Update with the owning Docker or Compose workflow.",
"postApplyChecking": "Verifying update result…",
"postApplyAllOk": "{count} package(s) applied successfully — nothing pending.",
"postApplyNothingPending": "Nothing pending — everything is up to date.",
"postApplyPartial": "{pending} package(s) still pending after the run.",
"postApplyPartialSubline": "{applied} applied. Some updates did not complete — review the terminal output above."
},
"bulkUpdate": {
"title": "Bulk update",
"description": "Configure one action to update the OS and the methods you select in this container.",
"loading": "Loading configuration…",
"notConfigured": "Not configured.",
"configure": "Configure",
"edit": "Edit",
"apply": "Apply updates",
"osTarget": "OS",
"osRequired": "Required for a bulk update.",
"includesDependencies": "Also includes declared dependencies: {names}",
"noMethod": "No executable update method is available.",
"staleTarget": "This selection is no longer available. Remove it before saving.",
"dockerInventoryPending": "Waiting for the Docker image inventory…",
"missingDockerTarget": "Docker image unavailable",
"selectAtLeastOne": "Select at least one update target in addition to the OS.",
"cancel": "Cancel",
"save": "Save",
"delete": "Remove configuration",
"deleteConfirm": "Remove this bulk update configuration?",
"saveFailed": "Could not save the bulk update configuration.",
"deleteFailed": "Could not remove the bulk update configuration.",
"planFailed": "Could not prepare the bulk update. Edit the configuration and review unavailable methods."
},
"appEditor": {
"closePanel": "Close panel",
"cancelButton": "Cancel",
@@ -1283,6 +1342,17 @@
"hiddenBadge": "Currently hidden — will re-appear in the detection list",
"registerDifferent": "Register a different app",
"detectedInContainer": "Detected on this container",
"versionDetected": "Version {version} detected in this container",
"legacyDetectorUsed": "Detected with a legacy compatibility fallback",
"dockerImagesTitle": "Docker image updates",
"dockerImagesHelp": "Tracks each local image separately from the Docker engine by comparing immutable registry digests.",
"refreshDockerImages": "Check now",
"noDockerImages": "No tagged Docker images found",
"usedByContainers": "Used by: {names}",
"imageUpdateAvailable": "New image available",
"imageUpToDate": "Up to date",
"imageDigestUnknown": "Digest unavailable",
"dockerReadOnlyNote": "Read-only check: no image is pulled and no container is restarted.",
"installedManaged": "Installed and managed by ProxMenux",
"nameLabel": "Name",
"installedViaLabel": "Installed via",
@@ -1339,6 +1409,17 @@
"jsonPathPlaceholder": "e.g., data.version or releases[0].tag_name",
"dockerImageLabel": "Docker Hub image",
"dockerImagePlaceholder": "e.g., linuxserver/plex or nginx",
"dockerVersionedTagsHelp": "Use this only for versioned tags. For latest, stable, or lts, use Docker image updates, which compares digests.",
"dockerPresetSemver": "SemVer only",
"dockerPresetSemverSuffix": "SemVer + suffix",
"dockerTagFilterHelp": "Filters real tags before ProxMenux selects the highest version.",
"dockerTagPreviewLabel": "Live tag preview",
"dockerTagPreviewCount": "{matched} matched of {scanned}",
"dockerTagPreviewLoading": "Checking Docker Hub…",
"dockerTagPreviewFailed": "Could not preview Docker Hub tags",
"dockerTagPreviewEmpty": "No real tags match this filter.",
"dockerMovingTag": "moving tag",
"dockerMovingTagHelp": "Moving tags do not contain a version. Track them in Docker image updates by digest instead.",
"tagRegexLabel": "Tag regex (with capture group)",
"tagRegexPlaceholder": "e.g., v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1375,6 +1456,13 @@
"noAppsTitle": "No applications registered",
"noAppsBody": "Register each application running in this container. Get clickable web links, and — optionally — upstream version tracking + notifications for new releases.",
"registerApplication": "Register application",
"searchApplications": "Find applications",
"searchingApplications": "Searching applications…",
"noApplicationsDetected": "No applications were detected.",
"noNewApplicationsDetected": "No new applications were detected.",
"oneNewApplicationDetected": "One new application was detected.",
"newApplicationsDetected": "{count} new applications were detected.",
"detectionFailed": "Application detection failed",
"hiddenSuffix": "{count} hidden",
"confirmHide": "Hide the \"{name}\" detection permanently on this container?\n\nTo bring it back later, click \"Register a different app\" — the hidden list appears there with a Restore option.",
"saveFailed": "Save failed",
@@ -1385,6 +1473,13 @@
"binaryArgsHintGrafana": "Grafana needs",
"loadFailed": "Could not load app configuration",
"checkFailed": "Check failed",
"testDetectorButton": "Test detector",
"testingDetectorButton": "Testing detector…",
"detectorTestTitle": "Detector test result",
"detectorTestNotSaved": "Draft only · not saved",
"detectorTestNoVersion": "No version was detected",
"detectorTestNoUpstream": "No upstream source configured",
"detectorTestFailed": "Detector test failed",
"deleteFailed": "Delete failed",
"dismissFailed": "Could not dismiss detection",
"restoreFailed": "Could not restore detection",
@@ -1401,6 +1496,10 @@
"webLinks": "Web links",
"addPort": "Add port",
"detectedPorts": "Ports detected in the container — click to add:",
"dockerPublishedServicesTitle": "Docker services with published ports",
"dockerPublishedServicesHelp": "Choose only ports that provide a web interface. ProxMenux does not register these containers as independent LXC applications.",
"dockerPublishedAdd": "Add link",
"dockerPublishedPort": "container port {containerPort} → {hostPort} on the LXC",
"noWebPorts": "No web ports listening. Use Add port if you still want to create a link manually.",
"trackUpstream": "Track upstream version (optional)",
"trackOff": "Off — link only",
@@ -1418,7 +1517,7 @@
"checkButton": "Check",
"editFieldsButton": "Edit fields",
"alsoDetectedContainer": "Also detected on this container",
"addAnotherApplication": "Add another application",
"addAnotherApplication": "Register another application",
"doneButton": "Done",
"editButton": "Edit",
"notificationsEnabled": "Upstream update notifications ON — click to mute",
+108 -9
View File
@@ -1163,7 +1163,15 @@
"targetBoth": "SO + aplicación",
"lastRun": "Última ejecución: {date}",
"runSuccess": "✓ éxito",
"runPartial": "completada parcialmente",
"runFailed": "✗ falló",
"runDeferred": "retenida por seguridad",
"runSkipped": "nada pendiente",
"releaseDelaySummary": "espera de {days} día(s)",
"releaseDelayLabel": "Esperar tras una nueva versión de app",
"releaseDelayNone": "Sin periodo de espera",
"releaseDelayDays": "Esperar {days} día(s)",
"releaseDelayHelp": "Solo se aplica a las apps seleccionadas con seguimiento de versión. Sus versiones pendientes deben tener esta antigüedad; las apps sin seguimiento ejecutan su actualizador en cada programación.",
"notScheduled": "No programado",
"frequency": "Frecuencia",
"cronExpression": "expresión cron",
@@ -1172,6 +1180,11 @@
"targetOptionOs": "Solo paquetes de sistema operativo",
"targetOptionApp": "Sólo aplicación",
"targetOptionBoth": "SO + aplicación",
"dockerComposeTarget": "Docker Compose · {project}",
"dockerContainerTarget": "Contenedor Docker · {container}",
"dockerEngineTarget": "Motor de Docker",
"noTargets": "Ningún objetivo seleccionado",
"selectAtLeastOne": "Seleccione al menos un objetivo de actualización.",
"deleteButton": "Eliminar horario",
"deleteConfirm": "¿Eliminar las actualizaciones programadas para este contenedor? Se mantienen los valores predeterminados de aplicación (copia de seguridad + reinicio)."
},
@@ -1217,7 +1230,7 @@
"osPlusApp": "SO + actualizaciones {appName}",
"osPlusApps": "Actualizaciones de SO y aplicaciones",
"noUpdateMethodTitle": "No hay ningún método de actualización disponible",
"noUpdateMethodBody": "No hay ningún método de actualización disponible. Actualice desde la propia aplicación o configure un comando personalizado en la pestaña Aplicación.",
"noUpdateMethodBody": "No se ha identificado un método de actualización para esta aplicación. Añada un comando de actualización personalizado.",
"ociImmutableTitle": "Contenedor de imágenes OCI",
"ociImmutableBody": "Los paquetes del sistema operativo se integran en el momento de crear la imagen y no se pueden actualizar en el lugar. Aplique las actualizaciones manualmente o reinstálelas con una imagen más nueva.",
"hideNoticeButton": "Ocultar este aviso para esta aplicación",
@@ -1229,7 +1242,8 @@
"securityUpdatesLabel": "actualizaciones de seguridad",
"applicationDefaultName": "Solicitud",
"installedLabel": "instalado",
"upToDateAtLabel": "Al día en",
"upToDateAtLabel": "Actualizada en la versión",
"versionLabel": "versión {version}",
"versionTrackingPending": "Seguimiento de versiones pendiente: la próxima comprobación programada mostrará las versiones instalada y disponible.",
"customCommandTitle": "Comando de actualización personalizado",
"customCommandBody": "Ejecute un comando de shell definido por el usuario para actualizar esta aplicación dentro del contenedor.",
@@ -1238,6 +1252,7 @@
"hideNoticeAction": "Ocultar este aviso para {appName}",
"detectedByPrefix": "Detectado por",
"editApp": "Editar",
"configureUpdater": "Configurar",
"alsoDetectedTitle": "También detectado en este contenedor:",
"detectedInline": "· detectado por {method}",
"customCommandLabel": "Comando de actualización personalizado",
@@ -1245,9 +1260,9 @@
"removeButton": "Eliminar",
"cancelButton": "Cancelar",
"saveButton": "Guardar",
"editCommandButton": "Editar comando",
"wireUpCommandButton": "Agregar comando de actualización personalizado",
"versionTrackingPendingShort": "Seguimiento de versión pendiente.",
"versionTrackingNotConfigured": "El seguimiento de versión no está configurado.",
"managedByOsPackages": "Esta aplicación se actualiza mediante la acción de paquetes del SO.",
"noMethodBody": "No hay ningún método de actualización disponible. Actualice desde la propia aplicación o conecte un comando personalizado que ProxMenux ejecutará dentro del contenedor.",
"noMethodHideBody": "Esta aplicación no tiene ningún método de actualización registrado. Configure uno desde la pestaña Aplicación o ignore este aviso.",
"hideForApp": "Ocultar aviso para {appName}",
@@ -1266,15 +1281,59 @@
"noManagedUpdateInfo": "Aún no hay información de actualización: verifique desde Seguridad → Secure Gateway.",
"ociTitle": "Contenedor de imágenes OCI",
"ociBody": "Este contenedor se creó a partir de una imagen OCI (Docker). La gestión de actualizaciones para contenedores OCI viene con la próxima función de instalación de OCI: las actualizaciones reconstruirán el contenedor a partir de una etiqueta de imagen más nueva en lugar de aplicar parches a los paquetes internos.",
"helperNotUpdateable": "El registro de scripts comunitarios marca esta aplicación como no actualizable.",
"helperDetectedTitle": "Detectado un actualizador de scripts auxiliares",
"helperDetectedBody": "La aplicación manual es la más segura.",
"dockerImagesTitle": "Imágenes Docker",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Imágenes",
"dockerImagesReadOnly": "Las etiquetas se comparan mediante el digest del registro. Los servicios Compose pueden descargar la imagen y recrearse desde su proyecto declarado.",
"dockerEngineManagedByOs": "Docker Engine {version} está gestionado por paquetes y se actualiza mediante la acción de paquetes del SO anterior.",
"dockerEngineSubheading": "Motor",
"dockerEngineDetected": "Docker Engine {version} detectado",
"dockerEngineUpdateHelp": "Actualiza únicamente los paquetes Docker instalados y sus dependencias necesarias. No actualiza otros paquetes del SO ni recrea contenedores.",
"updateDockerEngineOnly": "Actualizar Docker Engine",
"adguardWebUpdateOnly": "Esta instalación de AdGuard Home se actualiza desde su propia interfaz web.",
"openAdguard": "Abrir AdGuard Home",
"updateDockerImage": "Actualizar imagen",
"recreateStandaloneContainer": "Actualizar {container}",
"standaloneRecreatePending": "Estos contenedores independientes requieren una recreación protegida: {names}",
"noDockerImages": "Docker está instalado, pero no se encontraron imágenes con etiqueta.",
"dockerInventoryStarting": "Esperando a que Docker termine de arrancar…",
"dockerInventoryUnavailable": "No se pudo leer el inventario de Docker. Vuelva a comprobarlo cuando Docker esté disponible.",
"usedByContainers": "Usada por: {names}",
"imageUpdateAvailable": "Nueva imagen disponible",
"imageUpToDate": "Actualizada",
"imageInstalledTag": "etiqueta instalada",
"imageDigestUnknown": "Digest no disponible",
"dockerPendingSummary": "Se detectaron {count} actualización(es) de imágenes Docker. Actualízalas con su flujo de Docker o Compose.",
"postApplyChecking": "Comprobando resultado de la actualización…",
"postApplyAllOk": "{count} paquete(s) aplicados correctamente — nada pendiente.",
"postApplyNothingPending": "Nada pendiente — todo actualizado.",
"postApplyPartial": "{pending} paquete(s) siguen pendientes tras la ejecución.",
"postApplyPartialSubline": "{applied} aplicados. Algunas actualizaciones no finalizaron — revisa la salida del terminal."
},
"bulkUpdate": {
"title": "Actualización en bloque",
"description": "Configure una sola acción para actualizar el SO y los métodos que seleccione en este contenedor.",
"loading": "Cargando configuración…",
"notConfigured": "No configurada.",
"configure": "Configurar",
"edit": "Editar",
"apply": "Aplicar actualizaciones",
"osTarget": "SO",
"osRequired": "Obligatorio en una actualización en bloque.",
"includesDependencies": "Incluye también las dependencias declaradas: {names}",
"noMethod": "No hay ningún método de actualización ejecutable disponible.",
"staleTarget": "Esta selección ya no está disponible. Elimínela antes de guardar.",
"dockerInventoryPending": "Esperando al inventario de imágenes Docker…",
"missingDockerTarget": "Imagen Docker no disponible",
"selectAtLeastOne": "Seleccione al menos un método de actualización además del SO.",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar configuración",
"deleteConfirm": "¿Eliminar esta configuración de actualización en bloque?",
"saveFailed": "No se pudo guardar la configuración de actualización en bloque.",
"deleteFailed": "No se pudo eliminar la configuración de actualización en bloque.",
"planFailed": "No se pudo preparar la actualización en bloque. Edite la configuración y revise los métodos no disponibles."
},
"appEditor": {
"closePanel": "Cerrar panel",
"cancelButton": "Cancelar",
@@ -1284,6 +1343,17 @@
"hiddenBadge": "Actualmente oculto: volverá a aparecer en la lista de detección",
"registerDifferent": "Registrar una aplicación diferente",
"detectedInContainer": "Detectado en este contenedor",
"versionDetected": "Versión {version} detectada en este contenedor",
"legacyDetectorUsed": "Detectada mediante un fallback de compatibilidad antiguo",
"dockerImagesTitle": "Actualizaciones de imágenes Docker",
"dockerImagesHelp": "Comprueba cada imagen local por separado del motor Docker comparando digests inmutables del registro.",
"refreshDockerImages": "Comprobar ahora",
"noDockerImages": "No se encontraron imágenes Docker con etiqueta",
"usedByContainers": "Usada por: {names}",
"imageUpdateAvailable": "Nueva imagen disponible",
"imageUpToDate": "Actualizada",
"imageDigestUnknown": "Digest no disponible",
"dockerReadOnlyNote": "Comprobación de solo lectura: no descarga imágenes ni reinicia contenedores.",
"installedManaged": "Instalado y administrado por ProxMenux",
"nameLabel": "Nombre",
"installedViaLabel": "Instalado a través de",
@@ -1340,6 +1410,17 @@
"jsonPathPlaceholder": "por ejemplo, data.version o lanzamientos[0].tag_name",
"dockerImageLabel": "Imagen de Docker Hub",
"dockerImagePlaceholder": "por ejemplo, linuxserver/plex o nginx",
"dockerVersionedTagsHelp": "Úsalo solo con etiquetas versionadas. Para latest, stable o lts, usa Actualizaciones de imágenes Docker, que compara digests.",
"dockerPresetSemver": "Solo SemVer",
"dockerPresetSemverSuffix": "SemVer + sufijo",
"dockerTagFilterHelp": "Filtra las etiquetas reales antes de que ProxMenux elija la versión más alta.",
"dockerTagPreviewLabel": "Vista previa de etiquetas reales",
"dockerTagPreviewCount": "{matched} coinciden de {scanned}",
"dockerTagPreviewLoading": "Consultando Docker Hub…",
"dockerTagPreviewFailed": "No se pudieron consultar las etiquetas de Docker Hub",
"dockerTagPreviewEmpty": "Ninguna etiqueta real coincide con este filtro.",
"dockerMovingTag": "etiqueta móvil",
"dockerMovingTagHelp": "Las etiquetas móviles no contienen una versión. Contrólalas por digest en Actualizaciones de imágenes Docker.",
"tagRegexLabel": "Etiquetar expresiones regulares (con grupo de captura)",
"tagRegexPlaceholder": "por ejemplo, v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1375,7 +1456,14 @@
"registerCustom": "Registrar una aplicación personalizada",
"noAppsTitle": "No hay aplicaciones registradas",
"noAppsBody": "Registre las aplicaciones que se ejecutan en este contenedor. Obtendrá enlaces web y, opcionalmente, seguimiento de versiones disponibles y notificaciones de nuevos lanzamientos.",
"registerApplication": "Solicitud de registro",
"registerApplication": "Registrar aplicación",
"searchApplications": "Buscar aplicaciones",
"searchingApplications": "Buscando aplicaciones…",
"noApplicationsDetected": "No se han detectado aplicaciones.",
"noNewApplicationsDetected": "No se han detectado aplicaciones nuevas.",
"oneNewApplicationDetected": "Se ha detectado una aplicación nueva.",
"newApplicationsDetected": "Se han detectado {count} aplicaciones nuevas.",
"detectionFailed": "No se pudo completar la búsqueda de aplicaciones",
"hiddenSuffix": "{count} oculto",
"confirmHide": "¿Ocultar la detección \"{name}\" permanentemente en este contenedor?\n\nPara recuperarlo más tarde, haga clic en \"Registrar una aplicación diferente\"; la lista oculta aparece allí con una opción de Restaurar.",
"saveFailed": "Error al guardar",
@@ -1386,6 +1474,13 @@
"binaryArgsHintGrafana": "Grafana necesita",
"loadFailed": "No se pudo cargar la configuración de la aplicación",
"checkFailed": "Verificación fallida",
"testDetectorButton": "Probar detector",
"testingDetectorButton": "Probando detector…",
"detectorTestTitle": "Resultado de la prueba del detector",
"detectorTestNotSaved": "Solo borrador · sin guardar",
"detectorTestNoVersion": "No se detectó ninguna versión",
"detectorTestNoUpstream": "No hay una fuente upstream configurada",
"detectorTestFailed": "La prueba del detector ha fallado",
"deleteFailed": "Error al eliminar",
"dismissFailed": "No se pudo descartar la detección",
"restoreFailed": "No se pudo restaurar la detección",
@@ -1402,6 +1497,10 @@
"webLinks": "Enlaces web",
"addPort": "Agregar puerto",
"detectedPorts": "Puertos detectados en el contenedor: haga clic para agregar:",
"dockerPublishedServicesTitle": "Servicios Docker con puertos publicados",
"dockerPublishedServicesHelp": "Elige únicamente los puertos que ofrecen una interfaz web. ProxMenux no registra estos contenedores como aplicaciones LXC independientes.",
"dockerPublishedAdd": "Agregar enlace",
"dockerPublishedPort": "puerto {containerPort} del contenedor → {hostPort} en el LXC",
"noWebPorts": "No hay puertos web escuchando. Utilice Agregar puerto si aún desea crear un enlace manualmente.",
"trackUpstream": "Seguir versión disponible (opcional)",
"trackOff": "Desactivado: solo enlace",
@@ -1419,7 +1518,7 @@
"checkButton": "Controlar",
"editFieldsButton": "Editar campos",
"alsoDetectedContainer": "También detectado en este contenedor.",
"addAnotherApplication": "Agregar otra aplicación",
"addAnotherApplication": "Registrar otra aplicación",
"doneButton": "Hecho",
"editButton": "Editar",
"notificationsEnabled": "Notificaciones de actualización activas — clic para silenciar",
+107 -8
View File
@@ -1163,7 +1163,15 @@
"targetBoth": "Système d'exploitation + application",
"lastRun": "Dernière exécution : {date}",
"runSuccess": "✓ succès",
"runPartial": "partiellement terminée",
"runFailed": "✗ échoué",
"runDeferred": "reportée par sécurité",
"runSkipped": "rien en attente",
"releaseDelaySummary": "délai de {days} jour(s)",
"releaseDelayLabel": "Attendre après une nouvelle version dapplication",
"releaseDelayNone": "Aucun délai dattente",
"releaseDelayDays": "Attendre {days} jour(s)",
"releaseDelayHelp": "Sapplique uniquement aux applications sélectionnées dont les versions sont suivies. Leurs versions en attente doivent avoir cet âge ; les applications sans suivi exécutent leur programme de mise à jour à chaque programmation.",
"notScheduled": "Non programmé",
"frequency": "Fréquence",
"cronExpression": "Expression Cron",
@@ -1172,6 +1180,11 @@
"targetOptionOs": "Packages de système d'exploitation uniquement",
"targetOptionApp": "Candidature uniquement",
"targetOptionBoth": "Système d'exploitation + application",
"dockerComposeTarget": "Docker Compose · {project}",
"dockerContainerTarget": "Conteneur Docker · {container}",
"dockerEngineTarget": "Moteur Docker",
"noTargets": "Aucune cible sélectionnée",
"selectAtLeastOne": "Sélectionnez au moins une cible de mise à jour.",
"deleteButton": "Supprimer le planning",
"deleteConfirm": "Supprimer les mises à jour planifiées pour ce conteneur ? Les valeurs par défaut appliquées (sauvegarde + redémarrage) sont conservées."
},
@@ -1217,7 +1230,7 @@
"osPlusApp": "Mises à jour du système d'exploitation + {appName}",
"osPlusApps": "Mises à jour du système d'exploitation et des applications",
"noUpdateMethodTitle": "Aucune méthode de mise à jour disponible",
"noUpdateMethodBody": "Aucune méthode de mise à jour disponible. Mettez à jour depuis l'application elle-même ou définissez une commande personnalisée dans l'onglet Application.",
"noUpdateMethodBody": "Aucune méthode de mise à jour na été identifiée pour cette application. Ajoutez une commande de mise à jour personnalisée.",
"ociImmutableTitle": "Conteneur d'images OCI",
"ociImmutableBody": "Les packages du système d'exploitation sont intégrés au moment de la création de l'image et ne peuvent pas être mis à jour sur place. Appliquez les mises à jour manuellement ou réinstallez avec une image plus récente.",
"hideNoticeButton": "Masquer cet avis pour cette application",
@@ -1230,6 +1243,7 @@
"applicationDefaultName": "Application",
"installedLabel": "installé",
"upToDateAtLabel": "À jour à",
"versionLabel": "version {version}",
"versionTrackingPending": "Suivi des versions en attente : la prochaine vérification programmée indiquera les versions installée et disponible.",
"customCommandTitle": "Commande de mise à jour personnalisée",
"customCommandBody": "Exécutez une commande shell définie par l'utilisateur pour mettre à jour cette application dans le conteneur.",
@@ -1238,6 +1252,7 @@
"hideNoticeAction": "Masquer cet avis pour {appName}",
"detectedByPrefix": "Détecté par",
"editApp": "Modifier",
"configureUpdater": "Configurer",
"alsoDetectedTitle": "Également détecté dans ce conteneur :",
"detectedInline": "· détecté par {method}",
"customCommandLabel": "Commande de mise à jour personnalisée",
@@ -1245,9 +1260,9 @@
"removeButton": "Retirer",
"cancelButton": "Annuler",
"saveButton": "Sauvegarder",
"editCommandButton": "Modifier la commande",
"wireUpCommandButton": "Ajouter une commande de mise à jour personnalisée",
"versionTrackingPendingShort": "Suivi de version en attente.",
"versionTrackingNotConfigured": "Le suivi des versions nest pas configuré.",
"managedByOsPackages": "Cette application est mise à jour par laction sur les paquets du système.",
"noMethodBody": "Aucune méthode de mise à jour disponible. Mettez à jour à partir de l'application elle-même ou connectez une commande personnalisée que ProxMenux exécutera à l'intérieur du conteneur.",
"noMethodHideBody": "Cette application n'a aucune méthode de mise à jour enregistrée. Définissez-en un dans longlet Application ou ignorez cet avis.",
"hideForApp": "Masquer l'avis pour {appName}",
@@ -1266,15 +1281,59 @@
"noManagedUpdateInfo": "Aucune information de mise à jour pour l'instant - vérifiez depuis Sécurité → Secure Gateway.",
"ociTitle": "Conteneur d'images OCI",
"ociBody": "Ce conteneur a été créé à partir d'une image OCI (Docker). La gestion des mises à jour pour les conteneurs OCI est fournie avec la prochaine fonctionnalité d'installation OCI : les mises à jour reconstruiront le conteneur à partir d'une balise d'image plus récente plutôt que de corriger les packages à l'intérieur.",
"helperNotUpdateable": "Le registre des scripts de communauté marque cette application comme non modifiable.",
"helperDetectedTitle": "Détection d'un programme de mise à jour des scripts d'assistance",
"helperDetectedBody": "Lapplication manuelle est la plus sûre.",
"dockerImagesTitle": "Images Docker",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Images",
"dockerImagesReadOnly": "Les tags sont comparés par digest de registre. Les services Compose peuvent télécharger limage et être recréés depuis leur projet déclaré.",
"dockerEngineManagedByOs": "Docker Engine {version} est géré par paquets et mis à jour par laction des paquets du SE ci-dessus.",
"dockerEngineSubheading": "Moteur",
"dockerEngineDetected": "Docker Engine {version} détecté",
"dockerEngineUpdateHelp": "Met à jour uniquement les paquets Docker installés et les dépendances nécessaires. Les autres paquets du système et les conteneurs ne sont pas modifiés.",
"updateDockerEngineOnly": "Mettre à jour Docker Engine",
"adguardWebUpdateOnly": "Cette installation dAdGuard Home se met à jour depuis sa propre interface web.",
"openAdguard": "Ouvrir AdGuard Home",
"updateDockerImage": "Mettre à jour limage",
"recreateStandaloneContainer": "Mettre à jour {container}",
"standaloneRecreatePending": "Les conteneurs autonomes nécessitent une recréation protégée : {names}",
"noDockerImages": "Docker est installé, mais aucune image taguée na été trouvée.",
"dockerInventoryStarting": "En attente de la fin du démarrage de Docker…",
"dockerInventoryUnavailable": "Linventaire Docker na pas pu être lu. Vérifiez à nouveau lorsque Docker sera disponible.",
"usedByContainers": "Utilisée par : {names}",
"imageUpdateAvailable": "Nouvelle image disponible",
"imageUpToDate": "À jour",
"imageInstalledTag": "tag installé",
"imageDigestUnknown": "Digest indisponible",
"dockerPendingSummary": "{count} mise(s) à jour dimage Docker détectée(s). Utilisez le flux Docker ou Compose correspondant.",
"postApplyChecking": "Vérification du résultat de la mise à jour…",
"postApplyAllOk": "{count} package(s) appliqué(s) avec succès  rien en attente.",
"postApplyNothingPending": "Rien en attente tout est à jour.",
"postApplyPartial": "{pending} package(s) toujours en attente après l'exécution.",
"postApplyPartialSubline": "{applied} appliqué.Certaines mises à jour n'ont pas été terminées  consultez la sortie du terminal ci-dessus."
},
"bulkUpdate": {
"title": "Mise à jour groupée",
"description": "Configurez une seule action pour mettre à jour le système et les méthodes sélectionnées dans ce conteneur.",
"loading": "Chargement de la configuration…",
"notConfigured": "Non configurée.",
"configure": "Configurer",
"edit": "Modifier",
"apply": "Appliquer les mises à jour",
"osTarget": "Système",
"osRequired": "Obligatoire pour une mise à jour groupée.",
"includesDependencies": "Inclut aussi les dépendances déclarées : {names}",
"noMethod": "Aucune méthode de mise à jour exécutable nest disponible.",
"staleTarget": "Cette sélection nest plus disponible. Retirez-la avant denregistrer.",
"dockerInventoryPending": "En attente de linventaire des images Docker…",
"missingDockerTarget": "Image Docker indisponible",
"selectAtLeastOne": "Sélectionnez au moins une cible de mise à jour en plus du système.",
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer la configuration",
"deleteConfirm": "Supprimer cette configuration de mise à jour groupée ?",
"saveFailed": "Impossible denregistrer la configuration de mise à jour groupée.",
"deleteFailed": "Impossible de supprimer la configuration de mise à jour groupée.",
"planFailed": "Impossible de préparer la mise à jour groupée. Vérifiez les méthodes indisponibles."
},
"appEditor": {
"closePanel": "Fermer le panneau",
"cancelButton": "Annuler",
@@ -1284,6 +1343,17 @@
"hiddenBadge": "Actuellement masqué — réapparaîtra dans la liste de détection",
"registerDifferent": "Enregistrez une autre application",
"detectedInContainer": "Détecté sur ce conteneur",
"versionDetected": "Version {version} détectée dans ce conteneur",
"legacyDetectorUsed": "Détectée avec une solution de compatibilité ancienne",
"dockerImagesTitle": "Mises à jour des images Docker",
"dockerImagesHelp": "Suit chaque image locale séparément du moteur Docker en comparant les digests immuables du registre.",
"refreshDockerImages": "Vérifier maintenant",
"noDockerImages": "Aucune image Docker taguée trouvée",
"usedByContainers": "Utilisée par : {names}",
"imageUpdateAvailable": "Nouvelle image disponible",
"imageUpToDate": "À jour",
"imageDigestUnknown": "Digest indisponible",
"dockerReadOnlyNote": "Vérification en lecture seule : aucune image téléchargée et aucun conteneur redémarré.",
"installedManaged": "Installé et géré par ProxMenux",
"nameLabel": "Nom",
"installedViaLabel": "Installé via",
@@ -1340,6 +1410,17 @@
"jsonPathPlaceholder": "par exemple, data.version ou releases[0].tag_name",
"dockerImageLabel": "Image DockerHub",
"dockerImagePlaceholder": "par exemple, linuxserver/plex ou nginx",
"dockerVersionedTagsHelp": "À utiliser uniquement pour les tags versionnés. Pour latest, stable ou lts, utilisez les mises à jour dimages Docker par digest.",
"dockerPresetSemver": "SemVer uniquement",
"dockerPresetSemverSuffix": "SemVer + suffixe",
"dockerTagFilterHelp": "Filtre les tags réels avant que ProxMenux choisisse la version la plus élevée.",
"dockerTagPreviewLabel": "Aperçu des tags réels",
"dockerTagPreviewCount": "{matched} correspondances sur {scanned}",
"dockerTagPreviewLoading": "Interrogation de Docker Hub…",
"dockerTagPreviewFailed": "Impossible dafficher les tags Docker Hub",
"dockerTagPreviewEmpty": "Aucun tag réel ne correspond à ce filtre.",
"dockerMovingTag": "tag mobile",
"dockerMovingTagHelp": "Les tags mobiles ne contiennent pas de version. Suivez-les plutôt par digest dans les mises à jour dimages Docker.",
"tagRegexLabel": "Tag regex (avec groupe de capture)",
"tagRegexPlaceholder": "par exemple, v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1372,7 +1453,14 @@
"registerCustom": "Enregistrez une application personnalisée",
"noAppsTitle": "Aucune candidature enregistrée",
"noAppsBody": "Enregistrez les applications exécutées dans ce conteneur pour obtenir des liens web et, si vous le souhaitez, le suivi des versions disponibles avec des notifications de nouvelles versions.",
"registerApplication": "Enregistrer une demande",
"registerApplication": "Enregistrer lapplication",
"searchApplications": "Rechercher des applications",
"searchingApplications": "Recherche dapplications…",
"noApplicationsDetected": "Aucune application détectée.",
"noNewApplicationsDetected": "Aucune nouvelle application détectée.",
"oneNewApplicationDetected": "Une nouvelle application détectée.",
"newApplicationsDetected": "{count} nouvelles applications détectées.",
"detectionFailed": "Échec de la détection des applications",
"hiddenSuffix": "{count} masqué",
"confirmHide": "Masquer définitivement la détection « {name} » sur ce conteneur ?\n\nPour la récupérer plus tard, cliquez sur « Enregistrer une autre application » : la liste masquée y apparaît avec une option de restauration.",
"saveFailed": "Échec de l'enregistrement",
@@ -1383,6 +1471,13 @@
"binaryArgsHintGrafana": "Grafana a besoin",
"loadFailed": "Impossible de charger la configuration de l'application",
"checkFailed": "La vérification a échoué",
"testDetectorButton": "Tester le détecteur",
"testingDetectorButton": "Test du détecteur…",
"detectorTestTitle": "Résultat du test du détecteur",
"detectorTestNotSaved": "Brouillon uniquement · non enregistré",
"detectorTestNoVersion": "Aucune version détectée",
"detectorTestNoUpstream": "Aucune source amont configurée",
"detectorTestFailed": "Échec du test du détecteur",
"deleteFailed": "Échec de la suppression",
"dismissFailed": "Impossible d'ignorer la détection",
"restoreFailed": "Impossible de restaurer la détection",
@@ -1399,6 +1494,10 @@
"webLinks": "Liens Internet",
"addPort": "Ajouter un port",
"detectedPorts": "Ports détectés dans le conteneur — cliquez pour ajouter :",
"dockerPublishedServicesTitle": "Services Docker avec des ports publiés",
"dockerPublishedServicesHelp": "Sélectionnez uniquement les ports qui fournissent une interface web. ProxMenux nenregistre pas ces conteneurs comme des applications LXC indépendantes.",
"dockerPublishedAdd": "Ajouter le lien",
"dockerPublishedPort": "port {containerPort} du conteneur → {hostPort} dans le LXC",
"noWebPorts": "Aucun port Web n'écoute. Utilisez Ajouter un port si vous souhaitez toujours créer un lien manuellement.",
"trackUpstream": "Suivre la version disponible (facultatif)",
"trackOff": "Désactivé : lien uniquement",
@@ -1416,7 +1515,7 @@
"checkButton": "Vérifier",
"editFieldsButton": "Modifier les champs",
"alsoDetectedContainer": "Également détecté sur ce conteneur",
"addAnotherApplication": "Ajouter une autre application",
"addAnotherApplication": "Enregistrer une autre application",
"doneButton": "Fait",
"editButton": "Modifier",
"upstreamErrorTimeout": "expiration du délai d'attente du réseau lors du contact en amont",
+107 -8
View File
@@ -1163,7 +1163,15 @@
"targetBoth": "Sistema operativo + applicazione",
"lastRun": "Ultima esecuzione: {date}",
"runSuccess": "✓ successo",
"runPartial": "completato parzialmente",
"runFailed": "✗ fallito",
"runDeferred": "rinviato per sicurezza",
"runSkipped": "nulla in sospeso",
"releaseDelaySummary": "attesa di {days} giorno/i",
"releaseDelayLabel": "Attendi dopo una nuova versione dellapp",
"releaseDelayNone": "Nessun periodo di attesa",
"releaseDelayDays": "Attendi {days} giorno/i",
"releaseDelayHelp": "Si applica solo alle app selezionate con monitoraggio della versione. Le versioni in sospeso devono avere questa età; le app senza monitoraggio eseguono laggiornamento a ogni pianificazione.",
"notScheduled": "Non programmato",
"frequency": "Frequenza",
"cronExpression": "Espressione cron",
@@ -1172,6 +1180,11 @@
"targetOptionOs": "Solo pacchetti del sistema operativo",
"targetOptionApp": "Solo applicazione",
"targetOptionBoth": "Sistema operativo + applicazione",
"dockerComposeTarget": "Docker Compose · {project}",
"dockerContainerTarget": "Container Docker · {container}",
"dockerEngineTarget": "Motore Docker",
"noTargets": "Nessun obiettivo selezionato",
"selectAtLeastOne": "Seleziona almeno un obiettivo di aggiornamento.",
"deleteButton": "Elimina pianificazione",
"deleteConfirm": "Rimuovere gli aggiornamenti pianificati per questo contenitore? Le impostazioni predefinite di applicazione (backup + riavvio) vengono mantenute."
},
@@ -1217,7 +1230,7 @@
"osPlusApp": "Sistema operativo + {appName} aggiornamenti",
"osPlusApps": "Aggiornamenti del sistema operativo e delle app",
"noUpdateMethodTitle": "Nessun metodo di aggiornamento disponibile",
"noUpdateMethodBody": "Nessun metodo di aggiornamento disponibile. Aggiorna dall'app stessa o imposta un comando personalizzato nella scheda App.",
"noUpdateMethodBody": "Non è stato identificato alcun metodo di aggiornamento per questa applicazione. Aggiungi un comando di aggiornamento personalizzato.",
"ociImmutableTitle": "Contenitore di immagini OCI",
"ociImmutableBody": "I pacchetti del sistema operativo vengono integrati in fase di creazione dell'immagine e non possono essere aggiornati sul posto. Applica gli aggiornamenti manualmente o reinstallali con un'immagine più recente.",
"hideNoticeButton": "Nascondi questo avviso per questa app",
@@ -1230,6 +1243,7 @@
"applicationDefaultName": "Applicazione",
"installedLabel": "installato",
"upToDateAtLabel": "Aggiornato a",
"versionLabel": "versione {version}",
"versionTrackingPending": "Monitoraggio versioni in attesa: il prossimo controllo pianificato mostrerà la versione installata e quella disponibile.",
"customCommandTitle": "Comando di aggiornamento personalizzato",
"customCommandBody": "Esegui un comando shell definito dall'utente per aggiornare questa app all'interno del contenitore.",
@@ -1238,6 +1252,7 @@
"hideNoticeAction": "Nascondi questo avviso per {appName}",
"detectedByPrefix": "Rilevato da",
"editApp": "Modificare",
"configureUpdater": "Configura",
"alsoDetectedTitle": "Rilevato anche in questo contenitore:",
"detectedInline": "· rilevato da {method}",
"customCommandLabel": "Comando di aggiornamento personalizzato",
@@ -1245,9 +1260,9 @@
"removeButton": "Rimuovere",
"cancelButton": "Cancellare",
"saveButton": "Salva",
"editCommandButton": "Modifica comando",
"wireUpCommandButton": "Aggiungi comando di aggiornamento personalizzato",
"versionTrackingPendingShort": "Monitoraggio della versione in sospeso.",
"versionTrackingNotConfigured": "Il monitoraggio della versione non è configurato.",
"managedByOsPackages": "Questa applicazione viene aggiornata tramite lazione dei pacchetti del sistema operativo.",
"noMethodBody": "Nessun metodo di aggiornamento disponibile. Aggiorna dall'app stessa o collega un comando personalizzato che ProxMenux eseguirà all'interno del contenitore.",
"noMethodHideBody": "Per questa app non è registrato alcun metodo di aggiornamento. Impostane uno dalla scheda App o ignora questo avviso.",
"hideForApp": "Nascondi avviso per {appName}",
@@ -1266,15 +1281,59 @@
"noManagedUpdateInfo": "Nessuna informazione di aggiornamento ancora: controlla da Sicurezza → Secure Gateway.",
"ociTitle": "Contenitore di immagini OCI",
"ociBody": "Questo contenitore è stato creato da un'immagine OCI (Docker). La gestione degli aggiornamenti per i contenitori OCI arriverà con la prossima funzionalità di installazione OCI: gli aggiornamenti ricostruiranno il contenitore da un tag immagine più recente anziché applicare patch ai pacchetti all'interno.",
"helperNotUpdateable": "Il registro degli script della community contrassegna questa app come non aggiornabile.",
"helperDetectedTitle": "Rilevato un aggiornamento degli script helper",
"helperDetectedBody": "L'applicazione manuale è più sicura.",
"dockerImagesTitle": "Immagini Docker",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Immagini",
"dockerImagesReadOnly": "I tag vengono confrontati tramite digest del registro. I servizi Compose possono scaricare limmagine ed essere ricreati dal progetto dichiarato.",
"dockerEngineManagedByOs": "Docker Engine {version} è gestito dai pacchetti e viene aggiornato dallazione dei pacchetti del sistema operativo qui sopra.",
"dockerEngineSubheading": "Motore",
"dockerEngineDetected": "Docker Engine {version} rilevato",
"dockerEngineUpdateHelp": "Aggiorna solo i pacchetti Docker installati e le dipendenze necessarie. Non aggiorna altri pacchetti del sistema operativo né ricrea i container.",
"updateDockerEngineOnly": "Aggiorna Docker Engine",
"adguardWebUpdateOnly": "Questa installazione di AdGuard Home si aggiorna dalla propria interfaccia web.",
"openAdguard": "Apri AdGuard Home",
"updateDockerImage": "Aggiorna immagine",
"recreateStandaloneContainer": "Aggiorna {container}",
"standaloneRecreatePending": "I container indipendenti richiedono una ricreazione protetta: {names}",
"noDockerImages": "Docker è installato, ma non sono state trovate immagini con tag.",
"dockerInventoryStarting": "In attesa che Docker completi lavvio…",
"dockerInventoryUnavailable": "Non è stato possibile leggere linventario Docker. Controlla di nuovo quando Docker è disponibile.",
"usedByContainers": "Usata da: {names}",
"imageUpdateAvailable": "Nuova immagine disponibile",
"imageUpToDate": "Aggiornata",
"imageInstalledTag": "tag installato",
"imageDigestUnknown": "Digest non disponibile",
"dockerPendingSummary": "Rilevati {count} aggiornamenti di immagini Docker. Usa il relativo flusso Docker o Compose.",
"postApplyChecking": "Verifica del risultato dell'aggiornamento…",
"postApplyAllOk": "pacchetto/i {count} applicato correttamente: nulla in sospeso.",
"postApplyNothingPending": "nulla in sospeso: tutto è aggiornato.",
"postApplyPartial": "{pending} pacchetto/i ancora in sospeso dopo l'esecuzione.",
"postApplyPartialSubline": "{applied} applicato.Alcuni aggiornamenti non sono stati completati: esamina l'output del terminale sopra."
},
"bulkUpdate": {
"title": "Aggiornamento in blocco",
"description": "Configura una sola azione per aggiornare il sistema operativo e i metodi selezionati in questo container.",
"loading": "Caricamento configurazione…",
"notConfigured": "Non configurato.",
"configure": "Configura",
"edit": "Modifica",
"apply": "Applica aggiornamenti",
"osTarget": "Sistema operativo",
"osRequired": "Obbligatorio per un aggiornamento in blocco.",
"includesDependencies": "Include anche le dipendenze dichiarate: {names}",
"noMethod": "Non è disponibile alcun metodo di aggiornamento eseguibile.",
"staleTarget": "Questa selezione non è più disponibile. Rimuovila prima di salvare.",
"dockerInventoryPending": "In attesa dellinventario delle immagini Docker…",
"missingDockerTarget": "Immagine Docker non disponibile",
"selectAtLeastOne": "Seleziona almeno una destinazione di aggiornamento oltre al sistema operativo.",
"cancel": "Annulla",
"save": "Salva",
"delete": "Rimuovi configurazione",
"deleteConfirm": "Rimuovere questa configurazione di aggiornamento in blocco?",
"saveFailed": "Impossibile salvare la configurazione di aggiornamento in blocco.",
"deleteFailed": "Impossibile rimuovere la configurazione di aggiornamento in blocco.",
"planFailed": "Impossibile preparare laggiornamento in blocco. Controlla i metodi non disponibili."
},
"appEditor": {
"closePanel": "Chiudi pannello",
"cancelButton": "Cancellare",
@@ -1284,6 +1343,17 @@
"hiddenBadge": "Attualmente nascosto: riapparirà nell'elenco di rilevamento",
"registerDifferent": "Registra un'altra app",
"detectedInContainer": "Rilevato su questo contenitore",
"versionDetected": "Versione {version} rilevata in questo container",
"legacyDetectorUsed": "Rilevata con un fallback di compatibilità legacy",
"dockerImagesTitle": "Aggiornamenti immagini Docker",
"dockerImagesHelp": "Controlla ogni immagine locale separatamente dal motore Docker confrontando i digest immutabili del registro.",
"refreshDockerImages": "Controlla ora",
"noDockerImages": "Nessuna immagine Docker con tag trovata",
"usedByContainers": "Usata da: {names}",
"imageUpdateAvailable": "Nuova immagine disponibile",
"imageUpToDate": "Aggiornata",
"imageDigestUnknown": "Digest non disponibile",
"dockerReadOnlyNote": "Controllo in sola lettura: nessuna immagine viene scaricata e nessun container viene riavviato.",
"installedManaged": "Installato e gestito da ProxMenux",
"nameLabel": "Nome",
"installedViaLabel": "Installato tramite",
@@ -1340,6 +1410,17 @@
"jsonPathPlaceholder": "ad esempio, data.version o releases[0].tag_name",
"dockerImageLabel": "Immagine dell'hub Docker",
"dockerImagePlaceholder": "ad esempio, linuxserver/plex o nginx",
"dockerVersionedTagsHelp": "Usa questa opzione solo per tag con versione. Per latest, stable o lts usa gli aggiornamenti immagini Docker basati sul digest.",
"dockerPresetSemver": "Solo SemVer",
"dockerPresetSemverSuffix": "SemVer + suffisso",
"dockerTagFilterHelp": "Filtra i tag reali prima che ProxMenux scelga la versione più alta.",
"dockerTagPreviewLabel": "Anteprima tag reali",
"dockerTagPreviewCount": "{matched} corrispondenze su {scanned}",
"dockerTagPreviewLoading": "Controllo di Docker Hub…",
"dockerTagPreviewFailed": "Impossibile mostrare i tag di Docker Hub",
"dockerTagPreviewEmpty": "Nessun tag reale corrisponde a questo filtro.",
"dockerMovingTag": "tag mobile",
"dockerMovingTagHelp": "I tag mobili non contengono una versione. Tracciali tramite digest negli aggiornamenti immagini Docker.",
"tagRegexLabel": "Tag regex (con gruppo di acquisizione)",
"tagRegexPlaceholder": "ad esempio, v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1372,7 +1453,14 @@
"registerCustom": "Registra un'app personalizzata",
"noAppsTitle": "Nessuna domanda registrata",
"noAppsBody": "Registra le applicazioni in esecuzione nel contenitore per ottenere collegamenti web e, facoltativamente, il monitoraggio delle versioni disponibili con notifiche delle nuove release.",
"registerApplication": "Registra l'applicazione",
"registerApplication": "Registra applicazione",
"searchApplications": "Cerca applicazioni",
"searchingApplications": "Ricerca applicazioni…",
"noApplicationsDetected": "Nessuna applicazione rilevata.",
"noNewApplicationsDetected": "Nessuna nuova applicazione rilevata.",
"oneNewApplicationDetected": "Rilevata una nuova applicazione.",
"newApplicationsDetected": "Rilevate {count} nuove applicazioni.",
"detectionFailed": "Rilevamento delle applicazioni non riuscito",
"hiddenSuffix": "{count} nascosto",
"confirmHide": "Nascondere permanentemente il rilevamento \"{name}\" su questo contenitore?\n\nPer ripristinarlo in un secondo momento, fai clic su \"Registra un'altra app\": l'elenco nascosto viene visualizzato lì con un'opzione di ripristino.",
"saveFailed": "Salvataggio non riuscito",
@@ -1383,6 +1471,13 @@
"binaryArgsHintGrafana": "Grafana ha bisogno",
"loadFailed": "Impossibile caricare la configurazione dell'app",
"checkFailed": "Controllo fallito",
"testDetectorButton": "Prova rilevatore",
"testingDetectorButton": "Prova del rilevatore…",
"detectorTestTitle": "Risultato della prova del rilevatore",
"detectorTestNotSaved": "Solo bozza · non salvata",
"detectorTestNoVersion": "Nessuna versione rilevata",
"detectorTestNoUpstream": "Nessuna fonte upstream configurata",
"detectorTestFailed": "Prova del rilevatore non riuscita",
"deleteFailed": "Eliminazione non riuscita",
"dismissFailed": "Impossibile ignorare il rilevamento",
"restoreFailed": "Impossibile ripristinare il rilevamento",
@@ -1399,6 +1494,10 @@
"webLinks": "Collegamenti Web",
"addPort": "Aggiungi porto",
"detectedPorts": "Porte rilevate nel contenitore: fai clic per aggiungere:",
"dockerPublishedServicesTitle": "Servizi Docker con porte pubblicate",
"dockerPublishedServicesHelp": "Seleziona solo le porte che forniscono uninterfaccia web. ProxMenux non registra questi container come applicazioni LXC indipendenti.",
"dockerPublishedAdd": "Aggiungi link",
"dockerPublishedPort": "porta {containerPort} del container → {hostPort} nellLXC",
"noWebPorts": "Nessuna porta web in ascolto. Utilizza Aggiungi porta se desideri comunque creare un collegamento manualmente.",
"trackUpstream": "Monitora la versione disponibile (facoltativo)",
"trackOff": "Disattivato: solo collegamento",
@@ -1416,7 +1515,7 @@
"checkButton": "Controllo",
"editFieldsButton": "Modifica campi",
"alsoDetectedContainer": "Rilevato anche su questo contenitore",
"addAnotherApplication": "Aggiungi un'altra applicazione",
"addAnotherApplication": "Registra un'altra applicazione",
"doneButton": "Fatto",
"editButton": "Modificare",
"upstreamErrorTimeout": "timeout della rete durante il contatto a monte",
+107 -8
View File
@@ -1163,7 +1163,15 @@
"targetBoth": "SO + aplicativo",
"lastRun": "Última execução: {date}",
"runSuccess": "✓ sucesso",
"runPartial": "concluída parcialmente",
"runFailed": "✗ falhou",
"runDeferred": "adiada por segurança",
"runSkipped": "nada pendente",
"releaseDelaySummary": "espera de {days} dia(s)",
"releaseDelayLabel": "Esperar após uma nova versão da app",
"releaseDelayNone": "Sem período de espera",
"releaseDelayDays": "Esperar {days} dia(s)",
"releaseDelayHelp": "Aplica-se apenas às apps selecionadas com acompanhamento de versões. As versões pendentes devem ter esta idade; as apps sem acompanhamento executam o atualizador em cada agendamento.",
"notScheduled": "Não agendado",
"frequency": "Freqüência",
"cronExpression": "Expressão Cron",
@@ -1172,6 +1180,11 @@
"targetOptionOs": "Somente pacotes de sistema operacional",
"targetOptionApp": "Somente aplicativo",
"targetOptionBoth": "SO + aplicativo",
"dockerComposeTarget": "Docker Compose · {project}",
"dockerContainerTarget": "Contentor Docker · {container}",
"dockerEngineTarget": "Motor Docker",
"noTargets": "Nenhum alvo selecionado",
"selectAtLeastOne": "Selecione pelo menos um alvo de atualização.",
"deleteButton": "Excluir programação",
"deleteConfirm": "Remover as atualizações agendadas para este contêiner? Os padrões de aplicação (backup + reinicialização) são mantidos."
},
@@ -1217,7 +1230,7 @@
"osPlusApp": "SO + atualizações de {appName}",
"osPlusApps": "Atualizações do sistema operacional + aplicativos",
"noUpdateMethodTitle": "Nenhum método de atualização disponível",
"noUpdateMethodBody": "Nenhum método de atualização disponível. Atualize a partir do próprio aplicativo ou defina um comando personalizado na guia Aplicativo.",
"noUpdateMethodBody": "Não foi identificado nenhum método de atualização para esta aplicação. Adicione um comando de atualização personalizado.",
"ociImmutableTitle": "Contêiner de imagem OCI",
"ociImmutableBody": "Os pacotes do sistema operacional são incorporados no momento da criação da imagem e não podem ser atualizados no local. Aplique as atualizações manualmente ou reinstale com uma imagem mais recente.",
"hideNoticeButton": "Ocultar este aviso para este app",
@@ -1230,6 +1243,7 @@
"applicationDefaultName": "Aplicativo",
"installedLabel": "instalado",
"upToDateAtLabel": "Atualizado em",
"versionLabel": "versão {version}",
"versionTrackingPending": "Rastreamento de versões pendente — a próxima verificação agendada mostrará as versões instalada e disponível.",
"customCommandTitle": "Comando de atualização personalizado",
"customCommandBody": "Execute um comando shell definido pelo usuário para atualizar este aplicativo dentro do contêiner.",
@@ -1238,6 +1252,7 @@
"hideNoticeAction": "Ocultar este aviso para {appName}",
"detectedByPrefix": "Detectado por",
"editApp": "Editar",
"configureUpdater": "Configurar",
"alsoDetectedTitle": "Também detectado neste contêiner:",
"detectedInline": "· detectado por {method}",
"customCommandLabel": "Comando de atualização personalizado",
@@ -1245,9 +1260,9 @@
"removeButton": "Remover",
"cancelButton": "Cancelar",
"saveButton": "Salvar",
"editCommandButton": "Editar comando",
"wireUpCommandButton": "Adicionar comando de atualização personalizado",
"versionTrackingPendingShort": "Rastreamento de versão pendente.",
"versionTrackingNotConfigured": "O acompanhamento de versões não está configurado.",
"managedByOsPackages": "Esta aplicação é atualizada através da ação de pacotes do SO.",
"noMethodBody": "Nenhum método de atualização disponível. Atualize a partir do próprio aplicativo ou conecte um comando personalizado que o ProxMenux executará dentro do contêiner.",
"noMethodHideBody": "Este aplicativo não possui nenhum método de atualização registrado. Defina um na guia Aplicativo ou ignore este aviso.",
"hideForApp": "Ocultar aviso para {appName}",
@@ -1266,15 +1281,59 @@
"noManagedUpdateInfo": "Nenhuma informação de atualização ainda — verifique em Segurança → Secure Gateway.",
"ociTitle": "Contêiner de imagem OCI",
"ociBody": "Este contêiner foi criado a partir de uma imagem OCI (Docker). O gerenciamento de atualizações para contêineres OCI vem com o próximo recurso de instalação do OCI as atualizações reconstruirão o contêiner a partir de uma tag de imagem mais recente, em vez de corrigir os pacotes internos.",
"helperNotUpdateable": "O registro de scripts da comunidade marca este aplicativo como não atualizável.",
"helperDetectedTitle": "Detectou um atualizador de scripts auxiliares",
"helperDetectedBody": "Aplicar manualmente é mais seguro.",
"dockerImagesTitle": "Imagens Docker",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Imagens",
"dockerImagesReadOnly": "As tags são comparadas pelo digest do registo. Os serviços Compose podem descarregar a imagem e ser recriados a partir do projeto declarado.",
"dockerEngineManagedByOs": "O Docker Engine {version} é gerido por pacotes e atualizado pela ação de pacotes do SO acima.",
"dockerEngineSubheading": "Motor",
"dockerEngineDetected": "Docker Engine {version} detetado",
"dockerEngineUpdateHelp": "Atualiza apenas os pacotes Docker instalados e as dependências necessárias. Não atualiza outros pacotes do SO nem recria contentores.",
"updateDockerEngineOnly": "Atualizar Docker Engine",
"adguardWebUpdateOnly": "Esta instalação do AdGuard Home é atualizada na sua própria interface web.",
"openAdguard": "Abrir AdGuard Home",
"updateDockerImage": "Atualizar imagem",
"recreateStandaloneContainer": "Atualizar {container}",
"standaloneRecreatePending": "Os contentores independentes requerem uma recriação protegida: {names}",
"noDockerImages": "O Docker está instalado, mas não foram encontradas imagens com tag.",
"dockerInventoryStarting": "A aguardar que o Docker termine de iniciar…",
"dockerInventoryUnavailable": "Não foi possível ler o inventário do Docker. Verifique novamente quando o Docker estiver disponível.",
"usedByContainers": "Usada por: {names}",
"imageUpdateAvailable": "Nova imagem disponível",
"imageUpToDate": "Atualizada",
"imageInstalledTag": "etiqueta instalada",
"imageDigestUnknown": "Digest indisponível",
"dockerPendingSummary": "Foram detetadas {count} atualizações de imagens Docker. Use o respetivo fluxo Docker ou Compose.",
"postApplyChecking": "Verificando o resultado da atualização…",
"postApplyAllOk": "{count} pacote(s) aplicado(s) com sucesso — nada pendente.",
"postApplyNothingPending": "Nada pendente — tudo está atualizado.",
"postApplyPartial": "{pending} pacote(s) ainda pendente(s) após a execução.",
"postApplyPartialSubline": "{applied} aplicado.Algumas atualizações não foram concluídas revise a saída do terminal acima."
},
"bulkUpdate": {
"title": "Atualização em bloco",
"description": "Configure uma única ação para atualizar o sistema operativo e os métodos selecionados neste contentor.",
"loading": "A carregar configuração…",
"notConfigured": "Não configurada.",
"configure": "Configurar",
"edit": "Editar",
"apply": "Aplicar atualizações",
"osTarget": "Sistema operativo",
"osRequired": "Obrigatório numa atualização em bloco.",
"includesDependencies": "Inclui também as dependências declaradas: {names}",
"noMethod": "Não está disponível nenhum método de atualização executável.",
"staleTarget": "Esta seleção já não está disponível. Remova-a antes de guardar.",
"dockerInventoryPending": "A aguardar o inventário de imagens Docker…",
"missingDockerTarget": "Imagem Docker indisponível",
"selectAtLeastOne": "Selecione pelo menos um destino de atualização além do sistema operativo.",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Remover configuração",
"deleteConfirm": "Remover esta configuração de atualização em bloco?",
"saveFailed": "Não foi possível guardar a configuração de atualização em bloco.",
"deleteFailed": "Não foi possível remover a configuração de atualização em bloco.",
"planFailed": "Não foi possível preparar a atualização em bloco. Reveja os métodos indisponíveis."
},
"appEditor": {
"closePanel": "Fechar painel",
"cancelButton": "Cancelar",
@@ -1284,6 +1343,17 @@
"hiddenBadge": "Atualmente oculto — reaparecerá na lista de detecção",
"registerDifferent": "Registre um aplicativo diferente",
"detectedInContainer": "Detectado neste contêiner",
"versionDetected": "Versão {version} detetada neste contentor",
"legacyDetectorUsed": "Detetada com um fallback de compatibilidade antigo",
"dockerImagesTitle": "Atualizações de imagens Docker",
"dockerImagesHelp": "Verifica cada imagem local separadamente do motor Docker comparando digests imutáveis do registo.",
"refreshDockerImages": "Verificar agora",
"noDockerImages": "Não foram encontradas imagens Docker com tag",
"usedByContainers": "Usada por: {names}",
"imageUpdateAvailable": "Nova imagem disponível",
"imageUpToDate": "Atualizada",
"imageDigestUnknown": "Digest indisponível",
"dockerReadOnlyNote": "Verificação só de leitura: nenhuma imagem é descarregada e nenhum contentor é reiniciado.",
"installedManaged": "Instalado e gerenciado por ProxMenux",
"nameLabel": "Nome",
"installedViaLabel": "Instalado via",
@@ -1340,6 +1410,17 @@
"jsonPathPlaceholder": "por exemplo, data.version ou releases[0].tag_name",
"dockerImageLabel": "Imagem do DockerHub",
"dockerImagePlaceholder": "por exemplo, linuxserver/plex ou nginx",
"dockerVersionedTagsHelp": "Utilize apenas com tags versionadas. Para latest, stable ou lts, use as atualizações de imagens Docker por digest.",
"dockerPresetSemver": "Apenas SemVer",
"dockerPresetSemverSuffix": "SemVer + sufixo",
"dockerTagFilterHelp": "Filtra as tags reais antes de o ProxMenux escolher a versão mais alta.",
"dockerTagPreviewLabel": "Pré-visualização de tags reais",
"dockerTagPreviewCount": "{matched} correspondências de {scanned}",
"dockerTagPreviewLoading": "A consultar o Docker Hub…",
"dockerTagPreviewFailed": "Não foi possível mostrar as tags do Docker Hub",
"dockerTagPreviewEmpty": "Nenhuma tag real corresponde a este filtro.",
"dockerMovingTag": "tag móvel",
"dockerMovingTagHelp": "As tags móveis não contêm uma versão. Acompanhe-as pelo digest nas atualizações de imagens Docker.",
"tagRegexLabel": "Tag regex (com grupo de captura)",
"tagRegexPlaceholder": "por exemplo, v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1372,7 +1453,14 @@
"registerCustom": "Registre um aplicativo personalizado",
"noAppsTitle": "Nenhum aplicativo cadastrado",
"noAppsBody": "Registre os aplicativos executados neste contêiner para obter links da web e, opcionalmente, rastrear versões disponíveis com notificações de novos lançamentos.",
"registerApplication": "Registrar aplicativo",
"registerApplication": "Registar aplicação",
"searchApplications": "Procurar aplicações",
"searchingApplications": "A procurar aplicações…",
"noApplicationsDetected": "Nenhuma aplicação detetada.",
"noNewApplicationsDetected": "Nenhuma aplicação nova detetada.",
"oneNewApplicationDetected": "Foi detetada uma nova aplicação.",
"newApplicationsDetected": "Foram detetadas {count} aplicações novas.",
"detectionFailed": "Falha ao detetar aplicações",
"hiddenSuffix": "{count} oculto",
"confirmHide": "Ocultar permanentemente a detecção \"{name}\" neste contêiner?\n\nPara recuperá-lo mais tarde, clique em “Registrar um aplicativo diferente” a lista oculta aparece com a opção Restaurar.",
"saveFailed": "Falha ao salvar",
@@ -1383,6 +1471,13 @@
"binaryArgsHintGrafana": "Necessidades de grafana",
"loadFailed": "Não foi possível carregar a configuração do aplicativo",
"checkFailed": "Falha na verificação",
"testDetectorButton": "Testar detetor",
"testingDetectorButton": "A testar o detetor…",
"detectorTestTitle": "Resultado do teste do detetor",
"detectorTestNotSaved": "Apenas rascunho · não guardado",
"detectorTestNoVersion": "Nenhuma versão detetada",
"detectorTestNoUpstream": "Nenhuma fonte upstream configurada",
"detectorTestFailed": "Falha no teste do detetor",
"deleteFailed": "Falha na exclusão",
"dismissFailed": "Não foi possível descartar a detecção",
"restoreFailed": "Não foi possível restaurar a detecção",
@@ -1399,6 +1494,10 @@
"webLinks": "Links da web",
"addPort": "Adicionar porta",
"detectedPorts": "Portas detectadas no contêiner — clique para adicionar:",
"dockerPublishedServicesTitle": "Serviços Docker com portas publicadas",
"dockerPublishedServicesHelp": "Selecione apenas as portas que fornecem uma interface Web. O ProxMenux não regista estes contentores como aplicações LXC independentes.",
"dockerPublishedAdd": "Adicionar ligação",
"dockerPublishedPort": "porta {containerPort} do contentor → {hostPort} no LXC",
"noWebPorts": "Nenhuma porta da web escutando. Use Adicionar porta se ainda quiser criar um link manualmente.",
"trackUpstream": "Rastrear versão disponível (opcional)",
"trackOff": "Desativado apenas link",
@@ -1416,7 +1515,7 @@
"checkButton": "Verificar",
"editFieldsButton": "Editar campos",
"alsoDetectedContainer": "Também detectado neste contêiner",
"addAnotherApplication": "Adicione outro aplicativo",
"addAnotherApplication": "Registar outra aplicação",
"doneButton": "Feito",
"editButton": "Editar",
"upstreamErrorTimeout": "Tempo limite da rede ao entrar em contato com o upstream",
+107 -8
View File
@@ -1162,7 +1162,15 @@
"targetBoth": "Systém + aplikácia",
"lastRun": "Posledné spustenie: {date}",
"runSuccess": "✓ úspešné",
"runPartial": "čiastočne dokončené",
"runFailed": "✗ zlyhalo",
"runDeferred": "odložené z bezpečnostných dôvodov",
"runSkipped": "nič nečaká",
"releaseDelaySummary": "odklad {days} deň/dní",
"releaseDelayLabel": "Počkať po novej verzii aplikácie",
"releaseDelayNone": "Bez čakacej doby",
"releaseDelayDays": "Počkať {days} deň/dní",
"releaseDelayHelp": "Platí iba pre vybrané aplikácie so sledovaním verzie. Ich čakajúce verzie musia dosiahnuť tento vek; aplikácie bez sledovania spustia aktualizátor pri každom naplánovanom termíne.",
"notScheduled": "Bez plánu",
"frequency": "Frekvencia",
"cronExpression": "Cron výraz",
@@ -1171,6 +1179,11 @@
"targetOptionOs": "Iba balíky systému",
"targetOptionApp": "Iba aplikáciu",
"targetOptionBoth": "Systém + aplikáciu",
"dockerComposeTarget": "Docker Compose · {project}",
"dockerContainerTarget": "Docker kontajner · {container}",
"dockerEngineTarget": "Docker Engine",
"noTargets": "Nie sú vybrané žiadne ciele",
"selectAtLeastOne": "Vyberte aspoň jeden cieľ aktualizácie.",
"deleteButton": "Odstrániť plán",
"deleteConfirm": "Odstrániť plánované aktualizácie tohto kontajnera? Predvolené nastavenia (záloha a reštart) zostanú zachované."
},
@@ -1216,7 +1229,7 @@
"osPlusApp": "Aktualizácie systému + {appName}",
"osPlusApps": "Aktualizácie systému + aplikácií",
"noUpdateMethodTitle": "Aktualizácia nie je nastavená",
"noUpdateMethodBody": "Pre túto aplikáciu nie je nastavený spôsob aktualizácie. Aktualizujte ju priamo v aplikácii alebo nastavte vlastný príkaz na karte Aplikácia.",
"noUpdateMethodBody": "Pre túto aplikáciu nebola identifikovaná žiadna metóda aktualizácie. Pridajte vlastný aktualizačný príkaz.",
"ociImmutableTitle": "Kontajner z OCI obrazu",
"ociImmutableBody": "Balíky systému sú súčasťou obrazu a nemožno ich aktualizovať priamo. Aktualizujte ich ručne alebo kontajner preinštalujte z novšieho obrazu.",
"hideNoticeButton": "Skryť toto upozornenie pre aplikáciu",
@@ -1229,6 +1242,7 @@
"applicationDefaultName": "Aplikácia",
"installedLabel": "nainštalované",
"upToDateAtLabel": "Aktuálna verzia",
"versionLabel": "verzia {version}",
"versionTrackingPending": "Čaká sa na údaje o verzii — nainštalovaná a dostupná verzia sa doplnia pri najbližšej plánovanej kontrole.",
"customCommandTitle": "Vlastný príkaz na aktualizáciu",
"customCommandBody": "Spustí v kontajneri vlastný shell príkaz, ktorý aktualizuje túto aplikáciu.",
@@ -1237,6 +1251,7 @@
"hideNoticeAction": "Skryť upozornenie pre {appName}",
"detectedByPrefix": "Zistené cez",
"editApp": "Upraviť",
"configureUpdater": "Nastaviť",
"alsoDetectedTitle": "V tomto kontajneri sa našli aj:",
"detectedInline": "· zistené cez {method}",
"customCommandLabel": "Vlastný príkaz na aktualizáciu",
@@ -1244,9 +1259,9 @@
"removeButton": "Odstrániť",
"cancelButton": "Zrušiť",
"saveButton": "Uložiť",
"editCommandButton": "Upraviť príkaz",
"wireUpCommandButton": "Pridať vlastný príkaz na aktualizáciu",
"versionTrackingPendingShort": "Čaká sa na údaje o verzii.",
"versionTrackingNotConfigured": "Sledovanie verzie nie je nastavené.",
"managedByOsPackages": "Táto aplikácia sa aktualizuje prostredníctvom akcie balíkov operačného systému.",
"noMethodBody": "Pre túto aplikáciu nie je nastavený spôsob aktualizácie. Aktualizujte ju priamo v aplikácii alebo pridajte vlastný príkaz, ktorý ProxMenux spustí v kontajneri.",
"noMethodHideBody": "Táto aplikácia nemá nastavený spôsob aktualizácie. Nastavte ho na karte Aplikácia alebo toto upozornenie skryte.",
"hideForApp": "Skryť upozornenie pre {appName}",
@@ -1265,15 +1280,59 @@
"noManagedUpdateInfo": "Zatiaľ nie sú žiadne informácie o aktualizácii skontrolujte ich v časti Zabezpečenie → Secure Gateway.",
"ociTitle": "Kontajner z OCI obrazu",
"ociBody": "Tento kontajner bol vytvorený z OCI (Docker) obrazu. Pripravovaná podpora aktualizácií OCI kontajnerov vytvorí kontajner znova z novšieho tagu obrazu namiesto aktualizácie balíkov vo vnútri.",
"helperNotUpdateable": "Register community-scripts označuje túto aplikáciu ako neaktualizovateľnú.",
"helperDetectedTitle": "Našiel sa aktualizačný skript z helper-scripts",
"helperDetectedBody": "Najbezpečnejšie je spustiť aktualizáciu ručne.",
"dockerImagesTitle": "Docker obrazy",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Obrazy",
"dockerImagesReadOnly": "Značky sa porovnávajú podľa digestu registra. Služby Compose môžu stiahnuť obraz a znova sa vytvoriť z deklarovaného projektu.",
"dockerEngineManagedByOs": "Docker Engine {version} je spravovaný balíkmi a aktualizuje sa vyššie uvedenou akciou balíkov OS.",
"dockerEngineSubheading": "Engine",
"dockerEngineDetected": "Zistený Docker Engine {version}",
"dockerEngineUpdateHelp": "Aktualizuje iba nainštalované balíky Docker a potrebné závislosti. Ostatné balíky OS ani kontajnery nemení.",
"updateDockerEngineOnly": "Aktualizovať Docker Engine",
"adguardWebUpdateOnly": "Táto inštalácia AdGuard Home sa aktualizuje vo vlastnom webovom rozhraní.",
"openAdguard": "Otvoriť AdGuard Home",
"updateDockerImage": "Aktualizovať obraz",
"recreateStandaloneContainer": "Aktualizovať {container}",
"standaloneRecreatePending": "Samostatné kontajnery vyžadujú chránené opätovné vytvorenie: {names}",
"noDockerImages": "Docker je nainštalovaný, ale nenašli sa žiadne označené obrazy.",
"dockerInventoryStarting": "Čaká sa na dokončenie spustenia Dockeru…",
"dockerInventoryUnavailable": "Inventár Dockeru sa nepodarilo načítať. Skontrolujte ho znova, keď bude Docker dostupný.",
"usedByContainers": "Používa: {names}",
"imageUpdateAvailable": "Dostupný nový obraz",
"imageUpToDate": "Aktuálne",
"imageInstalledTag": "nainštalovaná značka",
"imageDigestUnknown": "Digest nie je dostupný",
"dockerPendingSummary": "Zistené aktualizácie Docker obrazov: {count}. Použite príslušný postup Docker alebo Compose.",
"postApplyChecking": "Overuje sa výsledok aktualizácie…",
"postApplyAllOk": "{count} balíkov bolo úspešne použitých nič sa nečaká.",
"postApplyNothingPending": "Nič sa nečaká všetko je aktuálne.",
"postApplyPartial": "{pending} balíkov, ktoré po spustení stále čakajú.",
"postApplyPartialSubline": "Použilo sa {applied}. Niektoré aktualizácie sa nedokončili skontrolujte výstup terminálu vyššie."
},
"bulkUpdate": {
"title": "Hromadná aktualizácia",
"description": "Nastavte jednu akciu na aktualizáciu operačného systému a vybraných metód v tomto kontajneri.",
"loading": "Načítava sa konfigurácia…",
"notConfigured": "Nenakonfigurované.",
"configure": "Nastaviť",
"edit": "Upraviť",
"apply": "Použiť aktualizácie",
"osTarget": "Operačný systém",
"osRequired": "Povinné pre hromadnú aktualizáciu.",
"includesDependencies": "Zahŕňa aj deklarované závislosti: {names}",
"noMethod": "Nie je dostupná žiadna spustiteľná metóda aktualizácie.",
"staleTarget": "Tento výber už nie je dostupný. Pred uložením ho odstráňte.",
"dockerInventoryPending": "Čaká sa na inventár obrazov Docker…",
"missingDockerTarget": "Obraz Docker nie je dostupný",
"selectAtLeastOne": "Okrem operačného systému vyberte aspoň jeden cieľ aktualizácie.",
"cancel": "Zrušiť",
"save": "Uložiť",
"delete": "Odstrániť konfiguráciu",
"deleteConfirm": "Odstrániť túto konfiguráciu hromadnej aktualizácie?",
"saveFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo uložiť.",
"deleteFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo odstrániť.",
"planFailed": "Hromadnú aktualizáciu sa nepodarilo pripraviť. Skontrolujte nedostupné metódy."
},
"appEditor": {
"closePanel": "Zavrieť panel",
"cancelButton": "Zrušiť",
@@ -1283,6 +1342,17 @@
"hiddenBadge": "Momentálne skryté — po obnovení sa znova zobrazí v zozname nájdených aplikácií",
"registerDifferent": "Pridať inú aplikáciu",
"detectedInContainer": "Nájdené v tomto kontajneri",
"versionDetected": "Verzia {version} zistená v tomto kontajneri",
"legacyDetectorUsed": "Zistené pomocou staršieho kompatibilného detektora",
"dockerImagesTitle": "Aktualizácie Docker obrazov",
"dockerImagesHelp": "Kontroluje každý lokálny obraz oddelene od Docker enginu porovnaním nemenných digestov registra.",
"refreshDockerImages": "Skontrolovať teraz",
"noDockerImages": "Nenašli sa žiadne označené Docker obrazy",
"usedByContainers": "Používa: {names}",
"imageUpdateAvailable": "Dostupný nový obraz",
"imageUpToDate": "Aktuálne",
"imageDigestUnknown": "Digest nie je dostupný",
"dockerReadOnlyNote": "Kontrola iba na čítanie: žiadny obraz sa nesťahuje a žiadny kontajner sa nereštartuje.",
"installedManaged": "Nainštalované a spravované cez ProxMenux",
"nameLabel": "Názov",
"installedViaLabel": "Spôsob inštalácie",
@@ -1339,6 +1409,17 @@
"jsonPathPlaceholder": "napr. data.version alebo releases[0].tag_name",
"dockerImageLabel": "Obraz na Docker Hub",
"dockerImagePlaceholder": "napr. linuxserver/plex alebo nginx",
"dockerVersionedTagsHelp": "Použite iba pre verziované tagy. Pre latest, stable alebo lts použite aktualizácie Docker obrazov podľa digestu.",
"dockerPresetSemver": "Iba SemVer",
"dockerPresetSemverSuffix": "SemVer + prípona",
"dockerTagFilterHelp": "Filtruje skutočné tagy pred výberom najvyššej verzie.",
"dockerTagPreviewLabel": "Náhľad skutočných tagov",
"dockerTagPreviewCount": "{matched} zhodných z {scanned}",
"dockerTagPreviewLoading": "Kontroluje sa Docker Hub…",
"dockerTagPreviewFailed": "Tagy Docker Hub sa nepodarilo zobraziť",
"dockerTagPreviewEmpty": "Tomuto filtru nezodpovedá žiadny skutočný tag.",
"dockerMovingTag": "pohyblivý tag",
"dockerMovingTagHelp": "Pohyblivé tagy neobsahujú verziu. Sledujte ich podľa digestu v aktualizáciách Docker obrazov.",
"tagRegexLabel": "Regex tagu (so zachytávacou skupinou)",
"tagRegexPlaceholder": "napr. v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1371,7 +1452,14 @@
"registerCustom": "Pridať vlastnú aplikáciu",
"noAppsTitle": "Nie sú pridané žiadne aplikácie",
"noAppsBody": "Pridajte aplikácie, ktoré bežia v tomto kontajneri. Získate odkazy na ich webové rozhranie a voliteľne aj sledovanie dostupných verzií s upozorneniami na nové vydania.",
"registerApplication": "Pridať aplikáciu",
"registerApplication": "Registrovať aplikáciu",
"searchApplications": "Hľadať aplikácie",
"searchingApplications": "Hľadajú sa aplikácie…",
"noApplicationsDetected": "Neboli zistené žiadne aplikácie.",
"noNewApplicationsDetected": "Neboli zistené žiadne nové aplikácie.",
"oneNewApplicationDetected": "Bola zistená jedna nová aplikácia.",
"newApplicationsDetected": "Boli zistené {count} nové aplikácie.",
"detectionFailed": "Zisťovanie aplikácií zlyhalo",
"hiddenSuffix": "skryté: {count}",
"confirmHide": "Natrvalo skryť nájdenú aplikáciu „{name}“ v tomto kontajneri?\n\nAk ju budete chcieť neskôr vrátiť, kliknite na „Pridať inú aplikáciu“. V zozname skrytých aplikácií ju potom môžete obnoviť.",
"saveFailed": "Uloženie zlyhalo",
@@ -1382,6 +1470,13 @@
"binaryArgsHintGrafana": "Grafana potrebuje",
"loadFailed": "Nastavenia aplikácií sa nepodarilo načítať",
"checkFailed": "Kontrola zlyhala",
"testDetectorButton": "Otestovať detektor",
"testingDetectorButton": "Testovanie detektora…",
"detectorTestTitle": "Výsledok testu detektora",
"detectorTestNotSaved": "Iba koncept · neuložené",
"detectorTestNoVersion": "Nebola zistená žiadna verzia",
"detectorTestNoUpstream": "Nie je nastavený zdroj upstream",
"detectorTestFailed": "Test detektora zlyhal",
"deleteFailed": "Odstránenie zlyhalo",
"dismissFailed": "Nájdenú aplikáciu sa nepodarilo skryť",
"restoreFailed": "Nájdenú aplikáciu sa nepodarilo obnoviť",
@@ -1398,6 +1493,10 @@
"webLinks": "Webové odkazy",
"addPort": "Pridať port",
"detectedPorts": "Porty nájdené v kontajneri — kliknutím ich pridáte:",
"dockerPublishedServicesTitle": "Služby Docker so zverejnenými portmi",
"dockerPublishedServicesHelp": "Vyberte iba porty, ktoré poskytujú webové rozhranie. ProxMenux tieto kontajnery nezaregistruje ako samostatné aplikácie LXC.",
"dockerPublishedAdd": "Pridať odkaz",
"dockerPublishedPort": "port kontajnera {containerPort} → {hostPort} v LXC",
"noWebPorts": "Nenašli sa žiadne aktívne webové porty. Odkaz môžete vytvoriť ručne cez Pridať port.",
"trackUpstream": "Sledovať dostupnú verziu (voliteľné)",
"trackOff": "Vypnuté — iba odkaz",
@@ -1415,7 +1514,7 @@
"checkButton": "Skontrolovať",
"editFieldsButton": "Upraviť údaje",
"alsoDetectedContainer": "Ďalšie aplikácie nájdené v kontajneri",
"addAnotherApplication": "Pridať ďalšiu aplikáciu",
"addAnotherApplication": "Registrovať ďalšiu aplikáciu",
"doneButton": "Hotovo",
"editButton": "Upraviť",
"upstreamErrorTimeout": "Časový limit siete pri kontaktovaní upstream",
+107 -8
View File
@@ -1163,7 +1163,15 @@
"targetBoth": "OS + applikation",
"lastRun": "Senaste körningen: {date}",
"runSuccess": "✓ framgång",
"runPartial": "delvis slutförd",
"runFailed": "✗ misslyckades",
"runDeferred": "uppskjuten av säkerhetsskäl",
"runSkipped": "inget väntar",
"releaseDelaySummary": "{days} dagars väntetid",
"releaseDelayLabel": "Vänta efter en ny appversion",
"releaseDelayNone": "Ingen väntetid",
"releaseDelayDays": "Vänta {days} dag(ar)",
"releaseDelayHelp": "Gäller endast valda appar med versionsspårning. Deras väntande versioner måste vara så här gamla; appar utan versionsspårning kör sin uppdaterare vid varje schemalagt tillfälle.",
"notScheduled": "Inte schemalagt",
"frequency": "Frekvens",
"cronExpression": "Cron uttryck",
@@ -1172,6 +1180,11 @@
"targetOptionOs": "Endast OS-paket",
"targetOptionApp": "Endast ansökan",
"targetOptionBoth": "OS + applikation",
"dockerComposeTarget": "Docker Compose · {project}",
"dockerContainerTarget": "Docker-container · {container}",
"dockerEngineTarget": "Docker Engine",
"noTargets": "Inga mål valda",
"selectAtLeastOne": "Välj minst ett uppdateringsmål.",
"deleteButton": "Ta bort schema",
"deleteConfirm": "Vill du ta bort de schemalagda uppdateringarna för den här behållaren? Tillämpa standardvärden (säkerhetskopiering + omstart) behålls."
},
@@ -1217,7 +1230,7 @@
"osPlusApp": "OS + {appName} uppdateringar",
"osPlusApps": "OS + Apps-uppdateringar",
"noUpdateMethodTitle": "Ingen uppdateringsmetod tillgänglig",
"noUpdateMethodBody": "Ingen uppdateringsmetod tillgänglig. Uppdatera från själva appen eller ställ in ett anpassat kommando på fliken App.",
"noUpdateMethodBody": "Ingen uppdateringsmetod har identifierats för den här appen. Lägg till ett anpassat uppdateringskommando.",
"ociImmutableTitle": "OCI-bildbehållare",
"ociImmutableBody": "OS-paket bakas in vid bildbyggetid och kan inte uppdateras på plats. Tillämpa uppdateringar manuellt eller installera om med en nyare bild.",
"hideNoticeButton": "Dölj det här meddelandet för den här appen",
@@ -1230,6 +1243,7 @@
"applicationDefaultName": "Tillämpningen",
"installedLabel": "installerat",
"upToDateAtLabel": "Uppdaterad kl",
"versionLabel": "version {version}",
"versionTrackingPending": "Versionsspårning väntar nästa schemalagda kontroll visar installerad och tillgänglig version.",
"customCommandTitle": "Anpassat uppdateringskommando",
"customCommandBody": "Kör ett användardefinierat skalkommando för att uppdatera den här appen inuti behållaren.",
@@ -1238,6 +1252,7 @@
"hideNoticeAction": "Dölj detta meddelande för {appName}",
"detectedByPrefix": "Upptäckt av",
"editApp": "Redigera",
"configureUpdater": "Konfigurera",
"alsoDetectedTitle": "Detekteras även i den här behållaren:",
"detectedInline": "· upptäckt av {method}",
"customCommandLabel": "Anpassat uppdateringskommando",
@@ -1245,9 +1260,9 @@
"removeButton": "Ta bort",
"cancelButton": "Avbryt",
"saveButton": "Spara",
"editCommandButton": "Redigera kommando",
"wireUpCommandButton": "Lägg till anpassat uppdateringskommando",
"versionTrackingPendingShort": "Väntande versionsspårning.",
"versionTrackingNotConfigured": "Versionsspårning är inte konfigurerad.",
"managedByOsPackages": "Den här appen uppdateras via åtgärden för operativsystemspaket.",
"noMethodBody": "Ingen uppdateringsmetod tillgänglig. Uppdatera från själva appen, eller koppla upp ett anpassat kommando som ProxMenux kommer att köra inuti behållaren.",
"noMethodHideBody": "Denna app har ingen uppdateringsmetod registrerad. Ställ in en från fliken App eller avvisa det här meddelandet.",
"hideForApp": "Dölj meddelande för {appName}",
@@ -1266,15 +1281,59 @@
"noManagedUpdateInfo": "Ingen uppdateringsinformation ännu — kolla från Säkerhet → Secure Gateway.",
"ociTitle": "OCI-bildbehållare",
"ociBody": "Den här behållaren skapades från en OCI-bild (Docker). Uppdateringshantering för OCI-behållare kommer med den kommande OCI-installationsfunktionen - uppdateringar kommer att bygga om behållaren från en nyare bildtagg snarare än att patcha paket inuti.",
"helperNotUpdateable": "Community-scripts-registret markerar denna app som inte uppdateringsbar.",
"helperDetectedTitle": "Upptäckte en hjälparskriptuppdatering",
"helperDetectedBody": "Att applicera manuellt är säkrast.",
"dockerImagesTitle": "Docker-avbilder",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Avbilder",
"dockerImagesReadOnly": "Taggar jämförs med registrets digest. Compose-tjänster kan hämta avbilden och återskapas från sitt deklarerade projekt.",
"dockerEngineManagedByOs": "Docker Engine {version} hanteras som paket och uppdateras av OS-paketåtgärden ovan.",
"dockerEngineSubheading": "Motor",
"dockerEngineDetected": "Docker Engine {version} identifierad",
"dockerEngineUpdateHelp": "Uppdaterar endast installerade Docker-paket och nödvändiga beroenden. Andra operativsystemspaket och behållare ändras inte.",
"updateDockerEngineOnly": "Uppdatera Docker Engine",
"adguardWebUpdateOnly": "Den här installationen av AdGuard Home uppdateras i dess eget webbgränssnitt.",
"openAdguard": "Öppna AdGuard Home",
"updateDockerImage": "Uppdatera avbild",
"recreateStandaloneContainer": "Uppdatera {container}",
"standaloneRecreatePending": "Fristående containrar kräver skyddad återskapning: {names}",
"noDockerImages": "Docker är installerat men inga taggade avbilder hittades.",
"dockerInventoryStarting": "Väntar på att Docker ska starta klart…",
"dockerInventoryUnavailable": "Docker-inventeringen kunde inte läsas. Kontrollera igen när Docker är tillgängligt.",
"usedByContainers": "Används av: {names}",
"imageUpdateAvailable": "Ny avbild tillgänglig",
"imageUpToDate": "Uppdaterad",
"imageInstalledTag": "installerad tagg",
"imageDigestUnknown": "Digest saknas",
"dockerPendingSummary": "{count} uppdateringar av Docker-avbilder hittades. Använd tillhörande Docker- eller Compose-flöde.",
"postApplyChecking": "Verifierar uppdateringsresultat...",
"postApplyAllOk": "{count} paket(en) har tillämpats framgångsrikt — inget väntande.",
"postApplyNothingPending": "Inget väntande — allt är uppdaterat.",
"postApplyPartial": "{pending} paket som fortfarande väntar efter körningen.",
"postApplyPartialSubline": "{applied} tillämpas.Vissa uppdateringar slutfördes inte granska terminalutgången ovan."
},
"bulkUpdate": {
"title": "Gruppuppdatering",
"description": "Konfigurera en åtgärd som uppdaterar operativsystemet och de valda metoderna i behållaren.",
"loading": "Läser in konfiguration…",
"notConfigured": "Inte konfigurerad.",
"configure": "Konfigurera",
"edit": "Redigera",
"apply": "Tillämpa uppdateringar",
"osTarget": "Operativsystem",
"osRequired": "Krävs för en gruppuppdatering.",
"includesDependencies": "Inkluderar även deklarerade beroenden: {names}",
"noMethod": "Ingen körbar uppdateringsmetod är tillgänglig.",
"staleTarget": "Det här valet är inte längre tillgängligt. Ta bort det innan du sparar.",
"dockerInventoryPending": "Väntar på inventeringen av Docker-avbilder…",
"missingDockerTarget": "Docker-avbilden är inte tillgänglig",
"selectAtLeastOne": "Välj minst ett uppdateringsmål utöver operativsystemet.",
"cancel": "Avbryt",
"save": "Spara",
"delete": "Ta bort konfiguration",
"deleteConfirm": "Ta bort den här gruppuppdateringskonfigurationen?",
"saveFailed": "Det gick inte att spara gruppuppdateringskonfigurationen.",
"deleteFailed": "Det gick inte att ta bort gruppuppdateringskonfigurationen.",
"planFailed": "Det gick inte att förbereda gruppuppdateringen. Kontrollera otillgängliga metoder."
},
"appEditor": {
"closePanel": "Stäng panelen",
"cancelButton": "Avbryt",
@@ -1284,6 +1343,17 @@
"hiddenBadge": "För närvarande dold — kommer att dyka upp igen i upptäcktslistan",
"registerDifferent": "Registrera en annan app",
"detectedInContainer": "Upptäcks på den här behållaren",
"versionDetected": "Version {version} identifierad i den här containern",
"legacyDetectorUsed": "Upptäckt med en äldre kompatibilitetsreserv",
"dockerImagesTitle": "Uppdateringar av Docker-avbilder",
"dockerImagesHelp": "Kontrollerar varje lokal avbild separat från Docker-motorn genom att jämföra oföränderliga register-digests.",
"refreshDockerImages": "Kontrollera nu",
"noDockerImages": "Inga taggade Docker-avbilder hittades",
"usedByContainers": "Används av: {names}",
"imageUpdateAvailable": "Ny avbild tillgänglig",
"imageUpToDate": "Uppdaterad",
"imageDigestUnknown": "Digest saknas",
"dockerReadOnlyNote": "Skrivskyddad kontroll: ingen avbild hämtas och ingen container startas om.",
"installedManaged": "Installerad och hanterad av ProxMenux",
"nameLabel": "Namn",
"installedViaLabel": "Installerad via",
@@ -1340,6 +1410,17 @@
"jsonPathPlaceholder": "t.ex. data.version eller releases[0].tag_name",
"dockerImageLabel": "Docker Hub-bild",
"dockerImagePlaceholder": "t.ex. linuxserver/plex eller nginx",
"dockerVersionedTagsHelp": "Använd endast för versionerade taggar. För latest, stable eller lts använder du Docker-avbildningsuppdateringar med digest-jämförelse.",
"dockerPresetSemver": "Endast SemVer",
"dockerPresetSemverSuffix": "SemVer + suffix",
"dockerTagFilterHelp": "Filtrerar verkliga taggar innan ProxMenux väljer den högsta versionen.",
"dockerTagPreviewLabel": "Förhandsvisning av verkliga taggar",
"dockerTagPreviewCount": "{matched} matchade av {scanned}",
"dockerTagPreviewLoading": "Kontrollerar Docker Hub…",
"dockerTagPreviewFailed": "Kunde inte visa Docker Hub-taggar",
"dockerTagPreviewEmpty": "Inga verkliga taggar matchar filtret.",
"dockerMovingTag": "rörlig tagg",
"dockerMovingTagHelp": "Rörliga taggar innehåller ingen version. Spåra dem i stället via digest under Docker-avbildningsuppdateringar.",
"tagRegexLabel": "Taggregex (med fångstgrupp)",
"tagRegexPlaceholder": "t.ex. v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
@@ -1372,7 +1453,14 @@
"registerCustom": "Registrera en anpassad app",
"noAppsTitle": "Inga ansökningar registrerade",
"noAppsBody": "Registrera apparna som körs i behållaren för att få webblänkar och, om du vill, spårning av tillgängliga versioner med aviseringar om nya utgåvor.",
"registerApplication": "Registrera ansökan",
"registerApplication": "Registrera app",
"searchApplications": "Sök appar",
"searchingApplications": "Söker efter appar…",
"noApplicationsDetected": "Inga appar identifierades.",
"noNewApplicationsDetected": "Inga nya appar identifierades.",
"oneNewApplicationDetected": "En ny app identifierades.",
"newApplicationsDetected": "{count} nya appar identifierades.",
"detectionFailed": "Appidentifieringen misslyckades",
"hiddenSuffix": "{count} dold",
"confirmHide": "Vill du dölja \"{name}\"-detekteringen permanent på den här behållaren?\n\nFör att ta tillbaka den senare, klicka på \"Registrera en annan app\" - den dolda listan visas där med ett återställningsalternativ.",
"saveFailed": "Det gick inte att spara",
@@ -1383,6 +1471,13 @@
"binaryArgsHintGrafana": "Grafana behöver",
"loadFailed": "Det gick inte att läsa in appkonfigurationen",
"checkFailed": "Kontrollen misslyckades",
"testDetectorButton": "Testa detektor",
"testingDetectorButton": "Testar detektorn…",
"detectorTestTitle": "Resultat av detektortest",
"detectorTestNotSaved": "Endast utkast · inte sparat",
"detectorTestNoVersion": "Ingen version identifierades",
"detectorTestNoUpstream": "Ingen uppströmskälla är konfigurerad",
"detectorTestFailed": "Detektortestet misslyckades",
"deleteFailed": "Det gick inte att ta bort",
"dismissFailed": "Det gick inte att avvisa upptäckten",
"restoreFailed": "Det gick inte att återställa upptäckten",
@@ -1399,6 +1494,10 @@
"webLinks": "Webblänkar",
"addPort": "Lägg till port",
"detectedPorts": "Portar upptäckta i behållaren — klicka för att lägga till:",
"dockerPublishedServicesTitle": "Docker-tjänster med publicerade portar",
"dockerPublishedServicesHelp": "Välj endast portar som tillhandahåller ett webbgränssnitt. ProxMenux registrerar inte dessa containrar som fristående LXC-appar.",
"dockerPublishedAdd": "Lägg till länk",
"dockerPublishedPort": "containerport {containerPort} → {hostPort} i LXC",
"noWebPorts": "Inga webbportar lyssnar. Använd Lägg till port om du fortfarande vill skapa en länk manuellt.",
"trackUpstream": "Spåra tillgänglig version (valfritt)",
"trackOff": "Av endast länk",
@@ -1416,7 +1515,7 @@
"checkButton": "Kontrollera",
"editFieldsButton": "Redigera fält",
"alsoDetectedContainer": "Detekteras även på denna behållare",
"addAnotherApplication": "Lägg till ytterligare ett program",
"addAnotherApplication": "Registrera en annan app",
"doneButton": "Gjort",
"editButton": "Redigera",
"upstreamErrorTimeout": "Nätverkstimeout vid kontakt uppströms",
+4
View File
@@ -127,6 +127,10 @@ cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null ||
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
cp "$SCRIPT_DIR/update_docker_engine.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ update_docker_engine.py not found"
chmod +x "$APP_DIR/usr/bin/update_docker_engine.py" 2>/dev/null || true
cp "$APPIMAGE_ROOT/../json/app_tracking_hints.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ app_tracking_hints.json not found"
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
File diff suppressed because it is too large Load Diff
+39
View File
@@ -108,6 +108,32 @@ sock = Sock()
# Active terminal sessions
active_sessions = {}
_script_completion_hook = None
_script_completion_hook_lock = threading.Lock()
def set_script_completion_hook(callback):
"""Register the backend hook invoked after a streamed script exits."""
global _script_completion_hook
with _script_completion_hook_lock:
_script_completion_hook = callback
def _run_script_completion_hook(script_path, params, exit_code, duration_seconds):
with _script_completion_hook_lock:
callback = _script_completion_hook
if callback is None:
return
try:
callback(
script_path=script_path,
params=dict(params or {}),
exit_code=int(exit_code),
duration_seconds=max(0, int(duration_seconds)),
)
except Exception as exc:
print(f"[ProxMenux] script completion hook failed: {exc}", flush=True)
@terminal_bp.route('/api/terminal/health', methods=['GET'])
def terminal_health():
"""Health check for terminal service"""
@@ -470,6 +496,7 @@ def script_websocket(ws, session_id):
env['PYTHONUNBUFFERED'] = '1'
env['TERM'] = 'xterm-256color'
script_started_at = time.monotonic()
script_process = subprocess.Popen(
['/bin/bash', script_path],
stdin=slave_fd,
@@ -580,6 +607,18 @@ def script_websocket(ws, session_id):
script_process.wait()
exit_code = script_process.returncode if script_process.returncode is not None else 0
threading.Thread(
target=_run_script_completion_hook,
args=(
script_path,
params,
exit_code,
time.monotonic() - script_started_at,
),
daemon=True,
name=f'script-complete-{session_id}',
).start()
try:
ws.send(f'\r\n[Script exited with code {exit_code}]\r\n')
# Send an explicit terminal result before the connection is
File diff suppressed because it is too large Load Diff
+242 -107
View File
@@ -167,6 +167,26 @@ def _detect_nvidia_xfree86() -> Optional[dict]:
# libedgetpu1-std from Google's apt repo).
def _coral_pcie_hardware_present() -> bool:
"""True when a Coral PCIe/M.2 device (vendor 0x1ac1, Global Unichip
Corp.) is visible on the PCI bus. Used together with the gasket-dkms
package state to detect orphan installs left behind by the legacy
installer (`scripts/install_coral_pve.sh` before 2026-04) that
installed the DKMS driver unconditionally on USB-only hosts."""
try:
for entry in os.listdir("/sys/bus/pci/devices"):
try:
with open(f"/sys/bus/pci/devices/{entry}/vendor",
"r", encoding="utf-8") as fh:
if fh.read().strip() == "0x1ac1":
return True
except OSError:
continue
except OSError:
pass
return False
def _detect_coral_host() -> list[dict]:
out: list[dict] = []
@@ -180,61 +200,105 @@ def _detect_coral_host() -> list[dict]:
# knows the fork's patch level.
# 2. `dpkg-query gasket-dkms` — the Debian package version, only
# present when the user installed via .deb rather than the
# ProxMenux script.
# ProxMenux script. Package state matters: only `ok installed`
# is trusted as a real version; broken states surface as
# "package present but not usable" so the UI can offer cleanup
# instead of a spurious "update available".
# 3. `dkms status` — the upstream module version registered with
# DKMS, which is always the bare `1.0`. Useful as a "modules
# are present" indicator but doesn't reveal the fork patch
# level, so the update-availability check would always fire a
# false positive against feranick's `1.0-N` tags. Reported on
# .50 after a successful re-install kept showing the update
# notification.
pcie_version: Optional[str] = None
# false positive against feranick's `1.0-N` tags.
#
# Orphan detection: gasket-dkms package present + no PCIe/M.2
# hardware = residue from the legacy installer. `_gasket_orphan`
# is exposed so `install_coral.sh` and the notification pipeline
# can offer cleanup without ever calling it "an update".
pcie_hw_present = _coral_pcie_hardware_present()
marker_version: Optional[str] = None
try:
with open("/var/lib/proxmenux/coral_gasket_version",
"r", encoding="utf-8", errors="replace") as fh:
marker = fh.read().strip()
# Sanity check: the file should hold something that looks
# like a version tag, not an error message or empty line.
if marker and re.match(r"^[A-Za-z0-9._+-]+$", marker):
pcie_version = marker
marker_version = marker
except OSError:
pass
if not pcie_version:
try:
r = subprocess.run(
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
capture_output=True, text=True, timeout=3,
)
if r.returncode == 0 and "ok installed" in r.stdout:
pcie_version = r.stdout.split("|", 1)[1].strip()
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
if not pcie_version:
try:
r = subprocess.run(
["dkms", "status"], capture_output=True, text=True, timeout=3,
)
if r.returncode == 0:
for line in r.stdout.splitlines():
if line.startswith("gasket"):
# "gasket, 1.0, ..." or "gasket/1.0, ..."
m = re.match(r"^gasket[, /]([^,\s]+)", line)
if m:
pcie_version = m.group(1)
break
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
if pcie_version:
out.append({
# gasket-dkms package inspection: state + version, kept separate.
dpkg_state: str = "absent" # "healthy" | "broken" | "absent"
dpkg_version: Optional[str] = None
try:
r = subprocess.run(
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
capture_output=True, text=True, timeout=3,
)
if r.returncode == 0 and "|" in r.stdout:
status_part, _, version_part = r.stdout.partition("|")
if "ok installed" in status_part:
dpkg_state = "healthy"
dpkg_version = version_part.strip() or None
elif any(tok in status_part for tok in (
"half-configured", "half-installed", "unpacked",
"failed-config", "reinst-required", "trigger",
)):
dpkg_state = "broken"
dpkg_version = version_part.strip() or None
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
# Version resolution: emit only when we can actually trust it, i.e.
# the hardware is present AND either we have a marker file or the
# package is healthy. On broken or orphan states we intentionally
# omit `current_version` so the update comparator never fires a
# false "update available" against feranick's tags.
pcie_version: Optional[str] = None
if pcie_hw_present:
if marker_version:
pcie_version = marker_version
elif dpkg_state == "healthy" and dpkg_version:
pcie_version = dpkg_version
else:
# Fallback to dkms status ONLY when hardware is present and
# no better source exists. Kept for backwards compatibility
# with hosts that lost the marker file after a manual dkms
# rebuild but still have working hardware + working modules.
try:
r = subprocess.run(
["dkms", "status"], capture_output=True, text=True, timeout=3,
)
if r.returncode == 0:
for line in r.stdout.splitlines():
if line.startswith("gasket"):
m = re.match(r"^gasket[, /]([^,\s]+)", line)
if m:
pcie_version = m.group(1)
break
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
is_orphan = (dpkg_state != "absent") and not pcie_hw_present
# Emit the entry whenever we have a trustworthy version OR whenever
# there is package state to surface (broken / orphan). This lets the
# frontend and the notification pipeline see both healthy installs
# and the two remediation cases in the same registry shape.
if pcie_version or dpkg_state != "absent":
entry = {
"id": "coral-host-pcie",
"type": "coral_host",
"name": "Coral TPU Driver (gasket-dkms)",
"current_version": pcie_version,
"menu_label": "GPU & TPU → Coral TPU",
"menu_script": "scripts/gpu_tpu/install_coral.sh",
"_coral_variant": "pcie",
})
"_gasket_pkg_state": dpkg_state,
"_gasket_orphan": is_orphan,
"_gasket_pcie_hardware_present": pcie_hw_present,
}
if pcie_version:
entry["current_version"] = pcie_version
out.append(entry)
# USB — libedgetpu1-std (default) or libedgetpu1-max if the user
# opted into the overclocked runtime. Either one means the USB
@@ -551,7 +615,11 @@ _helpers_cache_lock = threading.RLock()
_helpers_cache: Optional[dict] = None
_helpers_cache_ts: float = 0.0
_UPDATE_SLUG_RE = re.compile(r"ct/([a-z0-9_-]+)\.sh")
_UPDATE_SLUG_RE = re.compile(r"ct/([a-z0-9._-]+)\.sh")
_BASE_OS_HELPER_SLUGS = frozenset({
"alpine", "archlinux", "archlinux-vm", "debian", "fedora",
"gentoo", "opensuse", "ubuntu",
})
def _fetch_helpers_cache() -> dict:
@@ -714,22 +782,30 @@ def _guess_helper_slug_from_hostname(hostname: str) -> Optional[str]:
return None
def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
"""Best-effort identification of the community-scripts slug for a CT.
def _identify_helper_slug(vmid: str, hostname: str) -> tuple[Optional[str], Optional[str]]:
"""Return ``(slug, evidence_source)`` for a community-scripts CT.
Primary: extract from /usr/bin/update (present on installs from a
reasonably modern community-scripts installer). Fallback: if the
CT carries a helper-scripts tag but /usr/bin/update is missing
(very old installs, or the file was removed), guess by
fuzzy-matching the hostname against the helpers_cache slug list.
``update_wrapper`` is executable evidence: the slug was extracted
from /usr/bin/update. ``tag_hostname`` is only an identity hint for
old installs and must never enable an update action by itself.
"""
slug = _probe_helper_scripts_slug(vmid)
if slug:
return slug
return slug, "update_wrapper"
tags = _probe_lxc_tags(vmid)
if not (tags & _HELPER_SCRIPTS_TAGS):
return None
return _guess_helper_slug_from_hostname(hostname)
return None, None
slug = _guess_helper_slug_from_hostname(hostname)
return (slug, "tag_hostname") if slug else (None, None)
def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
"""Backward-compatible identity-only wrapper.
Callers deciding whether an updater may run must use
:func:`_identify_helper_slug` and require ``update_wrapper``.
"""
return _identify_helper_slug(vmid, hostname)[0]
def _probe_lxc_os(vmid: str) -> Optional[str]:
@@ -766,7 +842,7 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
return None
def _detect_lxc_containers() -> list[dict]:
def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
"""Enumerate running Debian/Ubuntu CTs as registry entries.
OS detection is cached in the registry entry (`_os_family`), so the
@@ -809,6 +885,8 @@ def _detect_lxc_containers() -> list[dict]:
out: list[dict] = []
for ct in cts:
if only_vmid is not None and str(ct.get("vmid")) != str(int(only_vmid)):
continue
if ct["status"] != "running":
continue
vmid = ct["vmid"]
@@ -853,16 +931,23 @@ def _detect_lxc_containers() -> list[dict]:
# Jellyfin" rather than a generic "Update").
has_app_updater = False
helper_slug: Optional[str] = None
helper_slug_source: Optional[str] = None
helper_app_name: Optional[str] = None
helper_updateable_known = False # True when we found the slug in the cache
if not is_oci and not managed_oci_app:
helper_slug = _infer_helper_slug(vmid, ct.get("name") or "")
helper_slug, helper_slug_source = _identify_helper_slug(
vmid, ct.get("name") or ""
)
if helper_slug:
entry = _fetch_helpers_cache().get(helper_slug)
if entry:
helper_updateable_known = True
helper_app_name = entry.get("name") or helper_slug
has_app_updater = bool(entry.get("updateable"))
has_app_updater = bool(
helper_slug_source == "update_wrapper"
and helper_slug not in _BASE_OS_HELPER_SLUGS
and entry.get("updateable")
)
out.append({
"id": cid,
@@ -877,6 +962,7 @@ def _detect_lxc_containers() -> list[dict]:
"_managed_oci_app": managed_oci_app,
"_has_app_updater": has_app_updater,
"_helper_slug": helper_slug,
"_helper_slug_source": helper_slug_source,
"_helper_app_name": helper_app_name,
"_helper_updateable_known": helper_updateable_known,
})
@@ -904,6 +990,45 @@ def _normalise_detector_result(result: Any) -> list[dict]:
return []
def _merge_detected_entry(existing: dict, entry: dict, now: str) -> dict:
"""Refresh one registry row from detector evidence without touching peers."""
if existing.get("removed_at"):
existing.pop("removed_at", None)
existing["reactivated_at"] = now
for key in ("name", "current_version", "menu_label", "menu_script"):
if key in entry and entry[key] is not None:
existing[key] = entry[key]
for key, value in entry.items():
if key.startswith("_"):
existing[key] = value
existing["last_seen"] = now
return existing
def _new_detected_entry(entry: dict, now: str) -> dict:
new_entry = {
"id": entry["id"],
"type": entry.get("type", "unknown"),
"name": entry.get("name", entry["id"]),
"current_version": entry.get("current_version"),
"menu_label": entry.get("menu_label"),
"menu_script": entry.get("menu_script"),
"installed_by": "detected",
"first_seen": now,
"last_seen": now,
"update_check": {
"last_check": None,
"available": False,
"latest": None,
"error": None,
},
}
for key, value in entry.items():
if key.startswith("_"):
new_entry[key] = value
return new_entry
def detect_and_register() -> dict:
"""Run every detector, merge results into the registry, persist.
@@ -938,44 +1063,9 @@ def detect_and_register() -> dict:
# 1. Add new + reactivate / refresh existing.
for item_id, entry in discovered.items():
if item_id in index:
existing = items[index[item_id]]
# Reactivate if it was previously removed
if existing.get("removed_at"):
existing.pop("removed_at", None)
existing["reactivated_at"] = now
# Refresh metadata fields that may have evolved
for k in ("name", "current_version", "menu_label", "menu_script"):
if k in entry and entry[k] is not None:
existing[k] = entry[k]
# Preserve internal helpers like `_oci_app_id`
for k, v in entry.items():
if k.startswith("_"):
existing[k] = v
existing["last_seen"] = now
_merge_detected_entry(items[index[item_id]], entry, now)
else:
# Brand new entry
new_entry = {
"id": entry["id"],
"type": entry.get("type", "unknown"),
"name": entry.get("name", entry["id"]),
"current_version": entry.get("current_version"),
"menu_label": entry.get("menu_label"),
"menu_script": entry.get("menu_script"),
"installed_by": "detected",
"first_seen": now,
"last_seen": now,
"update_check": {
"last_check": None,
"available": False,
"latest": None,
"error": None,
},
}
# Carry over internals (`_oci_app_id` etc.)
for k, v in entry.items():
if k.startswith("_"):
new_entry[k] = v
items.append(new_entry)
items.append(_new_detected_entry(entry, now))
# 2. Mark missing items as removed (don't delete — preserve
# history so a reinstall doesn't lose the audit trail).
@@ -1588,6 +1678,23 @@ _CHECKERS: dict[str, Callable[[dict], dict]] = {
}
def _store_update_result(item: dict, result: dict) -> None:
"""Apply one checker result using the registry's canonical shape."""
item["update_check"] = {
"available": bool(result.get("available")),
"latest": result.get("latest"),
"last_check": result.get("last_check") or _now_iso(),
"error": result.get("error"),
}
if result.get("current") and not item.get("current_version"):
item["current_version"] = result["current"]
for extra_key in ("_packages", "_upgrade_kind", "_kernel",
"_kernel_note", "_count", "_security_count",
"_coral_variant", "_coral_pkg"):
if extra_key in result:
item["update_check"][extra_key] = result[extra_key]
def check_for_updates(force: bool = False) -> list[dict]:
"""Run every type-specific checker over active items, persist
the updated state, return the list of items that have an update
@@ -1622,25 +1729,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
result = {"available": False, "latest": None,
"last_check": _now_iso(), "error": str(e)}
it["update_check"] = {
"available": bool(result.get("available")),
"latest": result.get("latest"),
"last_check": result.get("last_check") or _now_iso(),
"error": result.get("error"),
}
if result.get("current") and not it.get("current_version"):
it["current_version"] = result["current"]
# Per-checker extras carried through into the persisted
# `update_check` blob. Add new keys here when a future
# checker needs to surface fields beyond available/latest.
# `_count` + `_security_count` were missing originally, so
# the LXC checker's counts dropped on the floor and the
# frontend badge couldn't render.
for extra_key in ("_packages", "_upgrade_kind", "_kernel",
"_kernel_note", "_count", "_security_count",
"_coral_variant", "_coral_pkg"):
if extra_key in result:
it["update_check"][extra_key] = result[extra_key]
_store_update_result(it, result)
if it["update_check"]["available"]:
updates_available.append(it)
@@ -1650,3 +1739,49 @@ def check_for_updates(force: bool = False) -> list[dict]:
_write_registry(reg)
return updates_available
def refresh_lxc(vmid: int) -> Optional[dict]:
"""Detect and refresh exactly one running LXC.
This is the lifecycle counterpart of the daily collector. It is called
after a stopped container starts and deliberately leaves every other
guest's registry row untouched.
"""
try:
target_vmid = int(vmid)
except (TypeError, ValueError):
return None
detected = _detect_lxc_containers(only_vmid=target_vmid)
if not detected:
return None
entry = detected[0]
item_id = entry["id"]
now = _now_iso()
with _lock:
reg = _read_registry()
items: list[dict] = list(reg.get("items", []))
target = next((item for item in items if item.get("id") == item_id), None)
if target is None:
target = _new_detected_entry(entry, now)
items.append(target)
else:
_merge_detected_entry(target, entry, now)
try:
result = _check_lxc_updates(target)
except Exception as exc:
result = {
"available": False,
"latest": None,
"last_check": _now_iso(),
"error": str(exc),
}
_store_update_result(target, result)
reg["items"] = items
reg["version"] = _SCHEMA_VERSION
reg["last_targeted_refresh"] = now
_write_registry(reg)
return dict(target)
+45 -2
View File
@@ -22,7 +22,7 @@ import sqlite3
import subprocess
import threading
from queue import Queue
from typing import Optional, Dict, Any, Tuple
from typing import Optional, Dict, Any, Tuple, Callable
from pathlib import Path
@@ -1939,8 +1939,16 @@ class TaskWatcher:
'vzmigrate': ('migration_start', 'INFO'),
}
def __init__(self, event_queue: Queue):
def __init__(
self,
event_queue: Queue,
guest_lifecycle_callback: Optional[Callable[[str, str, str], None]] = None,
):
self._queue = event_queue
# Reuse the exact PVE task transition already responsible for
# VM/CT lifecycle notifications. Consumers such as the modal cache
# can subscribe without introducing a second status poller.
self._guest_lifecycle_callback = guest_lifecycle_callback
self._running = False
self._thread: Optional[threading.Thread] = None
# `_hostname` is exposed as a @property below so every read returns
@@ -2251,6 +2259,31 @@ class TaskWatcher:
# Determine entity type from task type
entity = 'ct' if task_type.startswith('vz') else 'vm'
# A completed PVE lifecycle task is the existing source of truth for
# start/stop/restart notifications. Publish the same transition to
# the optional cache listener before notification-only suppression
# (backup/startup aggregation, disabled channels, cooldowns) so cache
# correctness never depends on whether a message is delivered.
lifecycle_actions = {
'qmstart': ('qemu', 'start'),
'qmstop': ('qemu', 'stop'),
'qmshutdown': ('qemu', 'stop'),
'qmreboot': ('qemu', 'reboot'),
'qmreset': ('qemu', 'reboot'),
'vzstart': ('lxc', 'start'),
'vzstop': ('lxc', 'stop'),
'vzshutdown': ('lxc', 'stop'),
'vzreboot': ('lxc', 'reboot'),
}
lifecycle = lifecycle_actions.get(task_type)
if (lifecycle and self._guest_lifecycle_callback
and not is_error and (status == 'OK' or is_warning)):
try:
self._guest_lifecycle_callback(vmid, lifecycle[0], lifecycle[1])
except Exception as exc:
print(f'[TaskWatcher] guest lifecycle callback failed for '
f'{lifecycle[0]} {vmid}: {exc}', flush=True)
# Backup completion/failure and replication events are handled
# EXCLUSIVELY by the PVE webhook, which delivers richer data (full
# logs, sizes, durations, filenames). TaskWatcher skips these to
@@ -3516,6 +3549,15 @@ class PollingCollector:
try:
import lxc_apps
lxc_apps.refresh_all_apps(force=False)
# Docker images have an independent lifecycle from both the OS
# packages and the Docker engine. Refresh their read-only
# registry digest inventory on the same daily cadence; this never
# pulls or recreates containers.
# This is the single automatic Docker registry comparison. Force
# the rolling pass itself so a user-triggered check shortly after
# yesterday's cycle cannot postpone the next automatic scan by an
# additional day. Normal UI reads remain cache-only for 24 hours.
lxc_apps.refresh_docker_inventories(force=True)
# After the refresh, emit `app_update_available` for every
# sidecar entry currently flagged with a pending upstream
# release. `check_app(force=False)` short-circuits on a
@@ -3526,6 +3568,7 @@ class PollingCollector:
# moment. `notification_manager` dedups by entity_id
# (vmid + app_id + latest_version) so repeated calls only
# deliver one notification per release.
lxc_apps.emit_all_pending_docker_stacks()
lxc_apps.emit_all_pending_updates()
except Exception as e:
print(f"[PollingCollector] lxc_apps refresh failed: {e}")
+19 -1
View File
@@ -520,6 +520,8 @@ _AGGREGATION_EXEMPT_EVENTS = frozenset({
# at once, so without this exemption only the first 1-2 land and
# the rest get buffered into a useless summary.
'app_update_available',
'docker_stack_update_available',
'lxc_update_applied',
})
@@ -791,6 +793,7 @@ class NotificationManager:
self._task_watcher: Optional[TaskWatcher] = None
self._polling_collector: Optional[PollingCollector] = None
self._dispatch_thread: Optional[threading.Thread] = None
self._guest_lifecycle_callback = None
# Webhook receiver (no thread, passive)
self._hook_watcher: Optional[ProxmoxHookWatcher] = None
@@ -982,6 +985,17 @@ class NotificationManager:
self._load_config()
return {'success': True, 'channels': list(self._channels.keys())}
def set_guest_lifecycle_callback(self, callback) -> None:
"""Attach a consumer to the existing PVE task lifecycle watcher.
Detection stays in TaskWatcherthe same source that emits VM/CT
start/stop notifications. This setter only lets Flask invalidate and
rebuild its guest caches when that already-detected event completes.
"""
self._guest_lifecycle_callback = callback
if self._task_watcher is not None:
self._task_watcher._guest_lifecycle_callback = callback
# ─── Server Mode (Background) ──────────────────────────────
def start(self):
@@ -1017,7 +1031,10 @@ class NotificationManager:
# polling collector keep the managed_installs registry, the
# error history, and the task state up to date.
self._journal_watcher = JournalWatcher(self._event_queue)
self._task_watcher = TaskWatcher(self._event_queue)
self._task_watcher = TaskWatcher(
self._event_queue,
guest_lifecycle_callback=self._guest_lifecycle_callback,
)
self._polling_collector = PollingCollector(self._event_queue)
self._journal_watcher.start()
@@ -1966,6 +1983,7 @@ class NotificationManager:
'coral_driver_update_available',
'secure_gateway_update_available',
'app_update_available',
'docker_stack_update_available',
# Security events that must not be silenced by stale cooldowns
# following a Monitor reinstall (Pedro Rico, 19/05).
'auth_fail',
+12 -4
View File
@@ -512,10 +512,7 @@ TEMPLATES = {
},
'lxc_update_applied': {
'title': '{hostname}: LXC {ct_name} ({vmid}) update {result}',
'body': (
'Container {ct_name} (CT {vmid}) — update {result}.\n'
'Target: {target} Duration: {duration}'
),
'body': '{details}',
'label': 'LXC update applied',
'group': 'vm_ct',
'default_enabled': True,
@@ -540,6 +537,16 @@ TEMPLATES = {
# never received the notification they explicitly asked for.
'default_enabled': True,
},
'docker_stack_update_available': {
'title': '{hostname}: Docker updates available on CT {vmid}',
'body': (
'Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n'
'{details}'
),
'label': 'Docker updates available',
'group': 'updates',
'default_enabled': True,
},
'vm_start': {
'title': '{hostname}: VM {vmname} ({vmid}) started',
'body': 'Virtual machine {vmname} (ID: {vmid}) is now running.',
@@ -1728,6 +1735,7 @@ EVENT_EMOJI = {
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'docker_stack_update_available': '\U0001F433',
'vm_start': '\u25B6\uFE0F', # play button
'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
'vm_stop': '\u23F9\uFE0F', # stop button
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Safely recreate one standalone Docker container inside an LXC.
The container's create-time Config/HostConfig is read from Docker's API,
the referenced image is pulled, and a replacement is validated before the
old container is removed. If create/start/validation fails, the original
container name and running state are restored.
Compose-owned containers are deliberately rejected: their declarative
project is the authoritative and safer update path.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
def pct_exec(vmid: int, argv: list[str], *, input_text: str | None = None, timeout: int = 300) -> subprocess.CompletedProcess:
return subprocess.run(
["/usr/sbin/pct", "exec", str(vmid), "--", *argv],
input=input_text,
capture_output=True,
text=True,
timeout=timeout,
)
def checked(vmid: int, argv: list[str], *, input_text: str | None = None, timeout: int = 300) -> str:
result = pct_exec(vmid, argv, input_text=input_text, timeout=timeout)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "command failed").strip()
raise RuntimeError(f"{' '.join(argv[:3])}: {detail}")
return result.stdout or ""
def inspect_one(vmid: int, name: str) -> dict:
payload = json.loads(checked(vmid, ["docker", "inspect", name], timeout=30))
if not isinstance(payload, list) or len(payload) != 1:
raise RuntimeError("docker inspect returned an unexpected response")
return payload[0]
def create_payload(inspect: dict, image: str) -> dict:
config = dict(inspect.get("Config") or {})
config["Image"] = image
host_config = dict(inspect.get("HostConfig") or {})
if host_config.get("AutoRemove"):
raise RuntimeError("containers with AutoRemove cannot be recreated safely")
endpoints: dict[str, dict] = {}
for network_name, endpoint in ((inspect.get("NetworkSettings") or {}).get("Networks") or {}).items():
if not NAME_RE.match(str(network_name)):
continue
# Preserve names/aliases and driver options, but deliberately let
# Docker allocate a fresh IP while the stopped rollback container
# still owns its old endpoint.
target: dict = {}
for key in ("Aliases", "Links", "DriverOpts"):
if endpoint.get(key) is not None:
target[key] = endpoint[key]
endpoints[str(network_name)] = target
return {
**config,
"HostConfig": host_config,
"NetworkingConfig": {"EndpointsConfig": endpoints},
}
def api_create(vmid: int, name: str, payload: dict) -> str:
body = json.dumps(payload, separators=(",", ":"))
result = pct_exec(
vmid,
[
"curl", "--silent", "--show-error", "--fail-with-body",
"--unix-socket", "/var/run/docker.sock",
"-H", "Content-Type: application/json",
"-X", "POST", "--data-binary", "@-",
f"http://localhost/v1.41/containers/create?name={name}",
],
input_text=body,
timeout=60,
)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout or "Docker create API failed").strip())
response = json.loads(result.stdout or "{}")
container_id = str(response.get("Id") or "")
if not container_id:
raise RuntimeError(str(response.get("message") or "Docker create API returned no container id"))
return container_id
def recreate(vmid: int, name: str) -> None:
original = inspect_one(vmid, name)
labels = ((original.get("Config") or {}).get("Labels") or {})
if labels.get("com.docker.compose.project"):
raise RuntimeError("container belongs to Docker Compose; use its project update action")
image = str((original.get("Config") or {}).get("Image") or "").strip()
if not image:
raise RuntimeError("container has no reusable image reference")
was_running = bool((original.get("State") or {}).get("Running"))
backup_name = f"{name}.proxmenux-rollback-{int(time.time())}"
replacement_created = False
print(f"=== Docker protected recreation: CT {vmid} / {name} ===", flush=True)
print(f"Image: {image}", flush=True)
print("Pulling the referenced image…", flush=True)
pull = pct_exec(vmid, ["docker", "pull", image], timeout=1800)
if pull.stdout:
print(pull.stdout.rstrip(), flush=True)
if pull.returncode != 0:
raise RuntimeError((pull.stderr or "docker pull failed").strip())
payload = create_payload(original, image)
try:
if was_running:
print("Stopping the original container…", flush=True)
checked(vmid, ["docker", "stop", "--time", "30", name], timeout=60)
print(f"Keeping rollback container as {backup_name}", flush=True)
checked(vmid, ["docker", "rename", name, backup_name], timeout=30)
print("Creating replacement from the inspected configuration…", flush=True)
api_create(vmid, name, payload)
replacement_created = True
if was_running:
checked(vmid, ["docker", "start", name], timeout=60)
deadline = time.time() + 20
while True:
state = inspect_one(vmid, name).get("State") or {}
if not state.get("Running"):
raise RuntimeError(str(state.get("Error") or "replacement stopped during validation"))
health = ((state.get("Health") or {}).get("Status") or "").lower()
if health == "unhealthy":
raise RuntimeError("replacement healthcheck is unhealthy")
if health != "starting" or time.time() >= deadline:
break
time.sleep(2)
print("Replacement validated; removing rollback container…", flush=True)
checked(vmid, ["docker", "rm", "-f", backup_name], timeout=60)
print("Docker container recreation completed successfully.", flush=True)
except Exception:
print("Recreation failed; restoring the original container…", file=sys.stderr, flush=True)
if replacement_created:
pct_exec(vmid, ["docker", "rm", "-f", name], timeout=60)
pct_exec(vmid, ["docker", "rename", backup_name, name], timeout=30)
if was_running:
pct_exec(vmid, ["docker", "start", name], timeout=60)
raise
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--vmid", required=True, type=int)
parser.add_argument("--container", required=True)
args = parser.parse_args()
if args.vmid <= 0 or not NAME_RE.match(args.container):
parser.error("invalid VMID or container name")
try:
recreate(args.vmid, args.container)
return 0
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr, flush=True)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Update only the Docker Engine stack inside one Proxmox LXC.
This intentionally does not run the community-scripts Docker updater:
that updater also performs a full apt/apk upgrade. ProxMenux resolves a
small allow-list of Docker packages that are already installed and asks the
guest package manager to upgrade only those packages (and required
dependencies). Static/manual installations without a supported package
manager fail closed instead of guessing how they were installed.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import time
APT_PACKAGES = (
"docker-ce",
"docker-ce-cli",
"docker-ce-rootless-extras",
"docker-buildx-plugin",
"docker-compose-plugin",
"docker-model-plugin",
"containerd.io",
"docker.io",
"docker-compose-v2",
"docker-compose",
"docker-buildx",
"docker-cli",
"containerd",
"runc",
"moby-engine",
"moby-cli",
"moby-buildx",
"moby-compose",
"moby-containerd",
)
APK_PACKAGES = (
"docker",
"docker-cli",
"docker-openrc",
"docker-cli-buildx",
"docker-cli-compose",
"docker-compose",
"containerd",
"runc",
)
RPM_PACKAGES = (
"docker-ce",
"docker-ce-cli",
"docker-ce-rootless-extras",
"docker-buildx-plugin",
"docker-compose-plugin",
"containerd.io",
"moby-engine",
"moby-cli",
"moby-buildx",
"moby-compose",
"moby-containerd",
)
def pct(vmid: int, argv: list[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["/usr/sbin/pct", "exec", str(vmid), "--", *argv],
text=True,
capture_output=capture,
check=False,
)
def command_exists(vmid: int, name: str) -> bool:
return pct(vmid, ["sh", "-c", f"command -v {name} >/dev/null 2>&1"]).returncode == 0
def docker_version(vmid: int) -> str:
result = pct(vmid, ["docker", "version", "--format", "{{.Server.Version}}"], capture=True)
return result.stdout.strip() if result.returncode == 0 else ""
def apt_installed(vmid: int) -> list[str]:
installed: list[str] = []
for package in APT_PACKAGES:
result = pct(
vmid,
["dpkg-query", "-W", "-f=${db:Status-Abbrev}", package],
capture=True,
)
if result.returncode == 0 and result.stdout.startswith("ii"):
installed.append(package)
return installed
def apk_installed(vmid: int) -> list[str]:
return [package for package in APK_PACKAGES if pct(vmid, ["apk", "info", "-e", package], capture=True).returncode == 0]
def rpm_installed(vmid: int) -> list[str]:
return [package for package in RPM_PACKAGES if pct(vmid, ["rpm", "-q", package], capture=True).returncode == 0]
def resolve_method(vmid: int) -> tuple[str, list[str]]:
if command_exists(vmid, "apt-get") and command_exists(vmid, "dpkg-query"):
return "apt", apt_installed(vmid)
if command_exists(vmid, "apk"):
return "apk", apk_installed(vmid)
if command_exists(vmid, "dnf") and command_exists(vmid, "rpm"):
return "dnf", rpm_installed(vmid)
if command_exists(vmid, "snap") and pct(vmid, ["snap", "list", "docker"], capture=True).returncode == 0:
return "snap", ["docker"]
return "unsupported", []
def run_update(vmid: int, method: str, packages: list[str]) -> int:
if method == "apt":
if pct(vmid, ["apt-get", "update"]).returncode != 0:
return 1
return pct(
vmid,
[
"env",
"DEBIAN_FRONTEND=noninteractive",
"apt-get",
"-y",
"-o",
"Dpkg::Options::=--force-confold",
"install",
"--only-upgrade",
*packages,
],
).returncode
if method == "apk":
if pct(vmid, ["apk", "update"]).returncode != 0:
return 1
return pct(vmid, ["apk", "upgrade", "--no-cache", *packages]).returncode
if method == "dnf":
return pct(vmid, ["dnf", "-y", "upgrade", *packages]).returncode
if method == "snap":
return pct(vmid, ["snap", "refresh", "docker"]).returncode
return 1
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--vmid", required=True, type=int)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if args.vmid <= 0:
parser.error("--vmid must be a positive integer")
before = docker_version(args.vmid)
if not before:
print("ERROR: Docker Engine is not running or was not detected in this container.", file=sys.stderr)
return 2
method, packages = resolve_method(args.vmid)
if method == "unsupported" or not packages:
print(
"ERROR: Docker was detected, but no supported packaged installation was found. "
"Configure a custom update command for this installation.",
file=sys.stderr,
)
return 3
print(f"Docker Engine before: {before}")
print(f"Update method: {method}")
print("Installed Docker stack: " + ", ".join(packages))
if args.dry_run:
print("Dry run: no packages were changed.")
return 0
print("--- Updating only the installed Docker Engine stack ---")
if run_update(args.vmid, method, packages) != 0:
print("ERROR: the Docker package update failed.", file=sys.stderr)
return 4
after = ""
for _ in range(12):
after = docker_version(args.vmid)
if after:
break
time.sleep(1)
if not after:
print("ERROR: Docker did not become available again after the package update.", file=sys.stderr)
return 5
print(f"Docker Engine after: {after}")
if after == before:
print("Docker Engine was already at the newest package version available.")
else:
print("Docker Engine updated successfully.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+138 -4
View File
@@ -1,8 +1,121 @@
{
"schema_version": 1,
"observed_at": "2026-08-06",
"notes": "Runtime-verified detectors for the LXC app catalog. Every entry is a detector proven to work on a real container. Contribution rules \u2014 accept ONLY: `detector` (required, with installed_via + method-specific fields + repo/tag_regex), optional `alt_detectors` (cross-method fallbacks), optional `file_fallbacks` (same-method secondary paths). REJECT anything that identifies a host: no IP addresses, no VMIDs, no hostnames, no `evidence` blocks with those fields. Report reproduction context in the PR description instead \u2014 the committed JSON must stay generic.",
"observed_at": "2026-08-21",
"notes": "Runtime-verified detectors for the LXC app catalog. Every entry is a detector proven to work on a real container, either in maintainer testing or a reviewed community confirmation. Contribution rules \u2014 accept ONLY: `detector` (required, with installed_via + method-specific fields + repo/tag_regex), optional `alt_detectors` (cross-method fallbacks), optional `file_fallbacks` (same-method secondary paths). REJECT anything that identifies a host: no IP addresses, no VMIDs, no hostnames, no `evidence` blocks with those fields. Report reproduction context in the PR description instead \u2014 the committed JSON must stay generic.",
"apps": {
"docker": {
"operational": true,
"install_scope": [
"community-script",
"manual"
],
"detector": {
"installed_via": "binary",
"binary_path": "/usr/bin/docker",
"binary_args": [
"--version"
],
"repo": "moby/moby",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
},
"portainer": {
"operational": true,
"name": "Portainer",
"install_scope": [
"docker"
],
"detector": {
"installed_via": "docker_exec",
"container_name": "portainer",
"binary_path": "/portainer",
"binary_args": [
"--version"
],
"repo": "portainer/portainer",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
},
"default_ports": [
9000,
9443
],
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/portainer.webp",
"website": "https://www.portainer.io/"
},
"audiobookshelf": {
"operational": true,
"name": "Audiobookshelf",
"install_scope": [
"community-script",
"manual-if-same-package"
],
"evidence": [
"community-confirmed:github-discussion-306"
],
"detector": {
"installed_via": "dpkg",
"package": "audiobookshelf",
"repo": "advplyr/audiobookshelf",
"github_source": "releases",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)"
}
},
"gitea": {
"operational": true,
"name": "Gitea",
"install_scope": [
"community-script",
"manual-if-same-package"
],
"evidence": [
"community-confirmed:github-discussion-306"
],
"detector": {
"installed_via": "apk",
"package": "gitea",
"repo": "go-gitea/gitea",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
},
"dispatcharr": {
"operational": true,
"name": "Dispatcharr",
"install_scope": [
"community-script"
],
"evidence": [
"community-confirmed:github-discussion-306"
],
"detector": {
"installed_via": "file",
"file_path": "/root/.dispatcharr",
"file_regex": "(\\d+\\.\\d+\\.\\d+)",
"repo": "Dispatcharr/Dispatcharr",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
},
"kavita": {
"operational": true,
"name": "Kavita",
"install_scope": [
"community-script"
],
"evidence": [
"community-confirmed:github-discussion-306"
],
"detector": {
"installed_via": "file",
"file_path": "/root/.kavita",
"file_regex": "(\\d+\\.\\d+\\.\\d+)",
"repo": "Kareadita/Kavita",
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
},
"qbittorrent": {
"detector": {
"installed_via": "binary",
@@ -29,8 +142,7 @@
]
},
"openwebui": {
"operational": false,
"remove_from_v1": true,
"operational": true,
"detector": {
"installed_via": "python_dist",
"python_path": "/root/.local/share/uv/tools/open-webui/bin/python",
@@ -47,6 +159,15 @@
"tag_regex": "(\\d+\\.\\d+\\.\\d+)"
}
},
"jellyfin": {
"detector": {
"installed_via": "dpkg",
"package": "jellyfin",
"repo": "jellyfin/jellyfin",
"github_source": "releases",
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)"
}
},
"adguard": {
"detector": {
"installed_via": "binary",
@@ -205,6 +326,19 @@
"github_source": "releases",
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
}
},
"searxng": {
"operational": true,
"install_scope": [
"community-script",
"manual-if-same-venv"
],
"detector": {
"installed_via": "python_dist",
"python_path": "/usr/local/searxng/searx-pyenv/bin/python",
"distribution": "searxng",
"installed_regex": "(\\d+\\.\\d+\\.\\d+\\+[0-9a-f]+)"
}
}
}
}
+229
View File
@@ -190,6 +190,215 @@ cleanup_broken_gasket_dkms() {
esac
}
# ============================================================
# Orphan gasket-dkms detection and assisted cleanup
# ============================================================
# The legacy Coral installer (`scripts/install_coral_pve.sh`, retired
# in April 2026) unconditionally installed the gasket-dkms .deb even
# on USB-only hosts. On modern kernels (6.12+) the upstream `gasket
# 1.0` source no longer compiles, so DKMS autoinstall fails, dpkg
# leaves the package half-configured, and every subsequent apt-get
# call errors out.
#
# On hosts without Coral PCIe/M.2 hardware, this package is pure
# residue with no functional purpose — the Coral USB path uses
# libedgetpu1 in userspace and does not need the kernel driver.
# We detect that combination (gasket-dkms present + no PCIe device
# on the bus) and offer explicit, opt-in cleanup.
#
# Design guardrails:
# * Only offered when CORAL_PCIE_COUNT == 0. Never runs on hosts
# with a Coral PCIe/M.2 device present, even if the package is
# broken — those users need the package, and the fix is a
# rebuild (via `install_gasket_apex_dkms`), not a purge.
# * User confirmation always required — nothing removes silently.
# * The Coral USB path (libedgetpu1-std / -max) is never touched.
# Set by `detect_orphan_gasket_dkms`. Empty when no orphan state
# is present; otherwise one of "healthy_orphan" (package installed
# cleanly but hardware absent) or "broken_orphan" (package in a
# half-configured / half-installed / unpacked state and hardware
# absent — this is DavidOliMar's case and blocks apt).
GASKET_ORPHAN_STATE=""
detect_orphan_gasket_dkms() {
GASKET_ORPHAN_STATE=""
# Hardware present -> not orphan, never touched by this flow.
[[ "$CORAL_PCIE_COUNT" -gt 0 ]] && return 0
local pkg_status
pkg_status=$(dpkg-query -W -f='${Status}' gasket-dkms 2>/dev/null || echo "")
[[ -z "$pkg_status" ]] && return 0 # package not installed at all
if [[ "$pkg_status" == *"ok installed"* ]]; then
GASKET_ORPHAN_STATE="healthy_orphan"
elif [[ "$pkg_status" == *"half-configured"* \
|| "$pkg_status" == *"half-installed"* \
|| "$pkg_status" == *"unpacked"* \
|| "$pkg_status" == *"failed-config"* \
|| "$pkg_status" == *"reinst-required"* ]]; then
GASKET_ORPHAN_STATE="broken_orphan"
fi
}
cleanup_orphan_gasket_dkms() {
# Return codes:
# 0 cleanup completed and every final verification passed
# 1 operator cancelled before any change was made
# 2 cleanup ran, but dpkg/DKMS could not be verified as healthy
local msg=""
msg+="\n$(translate 'A legacy gasket-dkms package was found on this host, but no Coral M.2 / PCIe hardware is present.')\n\n"
msg+="$(translate 'This package was installed by older versions of the ProxMenux Coral installer that placed the M.2 kernel driver on every system, including USB-only setups. It is not needed for Coral USB devices, which use libedgetpu1-std / libedgetpu1-max only.')\n\n"
if [[ "$GASKET_ORPHAN_STATE" == "broken_orphan" ]]; then
msg+="\Z1\Zb$(translate 'The package is currently in a broken state and is blocking apt updates on this system.')\Zn\n\n"
fi
msg+="\Zb$(translate 'This cleanup will:')\Zn\n"
msg+="$(translate 'Purge the gasket-dkms package')\n"
msg+="$(translate 'Remove every registered gasket DKMS version')\n"
msg+="$(translate 'Run apt-get install -f to complete any pending package configurations')\n\n"
if [[ "$CORAL_USB_COUNT" -gt 0 || "$CORAL_USB_INSTALLED" == "true" ]]; then
msg+="\Z2$(translate 'Your Coral USB device and its runtime (libedgetpu1) will NOT be affected.')\Zn\n\n"
fi
msg+="$(translate 'If you have a Coral M.2 / PCIe device that is physically installed but not detected by lspci, cancel here and check your hardware first before proceeding.')\n\n"
msg+="\Zb$(translate 'Do you want to proceed with the cleanup?')\Zn"
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(translate 'Legacy gasket-dkms detected')" \
--defaultno --yesno "$msg" 24 84; then
return 1
fi
show_proxmenux_logo
msg_title "$(translate 'Cleanup legacy gasket-dkms')"
export DEBIAN_FRONTEND=noninteractive
msg_info "$(translate 'Purging gasket-dkms package...')"
# Try the clean apt path first; fall back to dpkg force flags if the
# package state prevents apt from resolving the removal itself.
if ! apt-get remove --purge -y gasket-dkms >>"$LOG_FILE" 2>&1; then
dpkg --remove --force-remove-reinstreq gasket-dkms >>"$LOG_FILE" 2>&1 || true
dpkg --purge --force-all gasket-dkms >>"$LOG_FILE" 2>&1 || true
fi
# A host can retain more than the historical gasket/1.0 entry. Read
# every version known by DKMS and also include stale version trees
# that a broken package configuration may have left behind.
local versions=""
local version=""
local dkms_remove_failed=0
if command -v dkms >/dev/null 2>&1; then
versions=$({
dkms status 2>/dev/null \
| awk -F'[,/ ]+' '/^gasket/ {print $2}'
if [[ -d /var/lib/dkms/gasket ]]; then
find /var/lib/dkms/gasket -mindepth 1 -maxdepth 1 -type d \
-exec basename {} \; 2>/dev/null
fi
} | sed '/^$/d' | sort -u)
if [[ -n "$versions" ]]; then
msg_info "$(translate 'Removing every registered gasket DKMS version...')"
while IFS= read -r version; do
[[ -z "$version" ]] && continue
if ! dkms remove -m gasket -v "$version" --all >>"$LOG_FILE" 2>&1; then
dkms_remove_failed=1
fi
done <<<"$versions"
if [[ "$dkms_remove_failed" -eq 0 ]]; then
msg_ok "$(translate 'DKMS registrations removed.')"
else
msg_warn "$(translate 'Some DKMS removals reported errors; final verification will determine the result.')"
fi
fi
fi
local repair_failed=0
msg_info "$(translate 'Completing pending package configurations...')"
if apt-get install -f -y >>"$LOG_FILE" 2>&1; then
msg_ok "$(translate 'Package configurations completed.')"
else
repair_failed=1
msg_warn "$(translate 'Some packages still need attention; review') ${LOG_FILE}"
fi
# Final verification is authoritative. Any dpkg state whose second
# character is not "n" (not installed) or "c" (only config files)
# still represents package payload or unfinished package work.
local package_state=""
local package_remnant=""
local dkms_remnant=""
local audit_output=""
package_state=$(dpkg -l gasket-dkms 2>/dev/null \
| awk '$2 == "gasket-dkms" {print $1; exit}')
if [[ -n "$package_state" && ! "$package_state" =~ ^.[nc] ]]; then
package_remnant="$package_state"
fi
if command -v dkms >/dev/null 2>&1; then
dkms_remnant=$(dkms status 2>/dev/null \
| grep -E '^gasket([,/ ]|$)' \
| head -n1)
fi
audit_output=$(dpkg --audit 2>&1 || true)
if [[ -n "$audit_output" ]]; then
{
echo "---- dpkg --audit after legacy gasket-dkms cleanup ----"
printf '%s\n' "$audit_output"
} >>"$LOG_FILE"
fi
if [[ -n "$package_remnant" ]]; then
repair_failed=1
msg_warn "$(translate 'gasket-dkms is still reported by dpkg in state:') ${package_remnant}. $(translate 'Manual review is required.')"
else
msg_ok "$(translate 'gasket-dkms has been fully removed from this system.')"
fi
if [[ -n "$dkms_remnant" ]]; then
repair_failed=1
msg_warn "$(translate 'A gasket DKMS registration is still present:') ${dkms_remnant}"
else
msg_ok "$(translate 'No gasket DKMS registrations remain.')"
fi
if [[ -n "$audit_output" ]]; then
repair_failed=1
msg_warn "$(translate 'dpkg still reports unfinished package work; review') ${LOG_FILE}"
else
msg_ok "$(translate 'The dpkg package database is clean.')"
fi
# Clear the component marker only after gasket itself is confirmed
# absent. A separate dpkg audit problem must still make the overall
# operation fail, but should not leave a false Coral PCIe component.
if [[ -z "$package_remnant" && -z "$dkms_remnant" ]]; then
if declare -f update_component_status >/dev/null 2>&1; then
update_component_status "coral_driver" "removed" "" "gpu" '{}' >/dev/null 2>&1 || true
fi
rm -f /var/lib/proxmenux/coral_gasket_version 2>/dev/null || true
fi
if [[ "$repair_failed" -ne 0 ]]; then
echo
msg_error "$(translate 'Legacy gasket-dkms cleanup could not be verified as complete.')"
msg_warn "$(translate 'No reboot was started. Review the log before retrying:') ${LOG_FILE}"
return 2
fi
echo
msg_success "$(translate 'Cleanup completed. A reboot is recommended to fully apply pending kernel package configurations.')"
restart_prompt
return 0
}
clone_gasket_sources() {
# Primary: feranick/gasket-driver — community fork, actively maintained,
# carries patches for kernel 6.10/6.12/6.13.
@@ -655,10 +864,12 @@ restart_prompt() {
# Main orchestrator
# ============================================================
main() {
local cleanup_rc=0
: >"$LOG_FILE"
detect_coral_hardware
detect_coral_install_state
detect_orphan_gasket_dkms
# No hardware AND no leftover install → nothing to do.
if [[ "$CORAL_PCIE_COUNT" -eq 0 && "$CORAL_USB_COUNT" -eq 0 ]] \
@@ -667,6 +878,24 @@ main() {
exit 0
fi
# Legacy gasket-dkms package left behind by the retired installer
# (see detect_orphan_gasket_dkms header). Offer explicit cleanup
# before the normal action menu so the user sees a curated fix
# instead of a broken install/remove flow. If the operator cancels,
# we still fall through to the standard menu (they may want to act
# on the USB runtime independently).
if [[ -n "$GASKET_ORPHAN_STATE" ]]; then
if cleanup_orphan_gasket_dkms; then
exit 0
else
cleanup_rc=$?
# Return 1 means the operator cancelled and may still use the
# standard menu. A failed repair must stop here instead of
# continuing as though the package manager were healthy.
[[ "$cleanup_rc" -eq 1 ]] || exit "$cleanup_rc"
fi
fi
# If something is already installed, offer reinstall/uninstall choice.
# Same UX as nvidia_installer.sh. When nothing is installed yet,
# ACTION="install" automatically.
+140 -35
View File
@@ -10,14 +10,28 @@
# BACKUP — "1" to snapshot with vzdump first, "0" to skip
# BACKUP_STORAGE — PVE storage name for vzdump (required when BACKUP=1)
# RESTART — "1" to `pct reboot` after update, "0" to skip
# UPDATE_COMMAND — optional; user-defined bash string. When set
# RUN_HELPER — "1" to run the verified community-scripts
# updater referenced by /usr/bin/update, "0"
# to leave it alone. Never inferred from names.
# UPDATE_COMMAND — optional user-defined bash string. When set
# and TARGET is "app" or "both", the script
# runs this VIA sh -c inside the CT instead of
# /usr/bin/update. This IS the one place we
# runs it VIA sh -c inside the CT. A custom
# command always replaces RUN_HELPER for safety.
# This IS the one place we
# intentionally use sh -c with a variable
# payload — the threat model matches "user
# typed it via pct exec themselves"; ProxMenux
# does not compose or interpret the command.
# ALLOW_HELPER_WITH_CUSTOM — "1" only for an explicit multi-app plan
# where RUN_HELPER belongs to one registered app and
# UPDATE_COMMAND contains other registered apps. The
# default "0" preserves the custom-replaces-helper rule
# for single-app and legacy callers.
# DOCKER_STANDALONE_TARGETS — optional comma-separated Docker container
# names. Each is recreated transactionally by the
# protected host-side Docker recreation helper.
# UPDATE_DOCKER_ENGINE — "1" to update only the installed Docker Engine
# package stack, without upgrading unrelated OS packages.
#
# Exit codes:
# 0 everything requested completed OK
@@ -26,8 +40,9 @@
# 3 pre-update backup failed (abort so the user still has a rollback)
# 4 OS update failed OR OS family not supported for automated updates
# 5 TARGET=app requested but no update method (neither UPDATE_COMMAND
# nor /usr/bin/update) available in the CT
# nor explicitly-enabled verified helper) available in the CT
# 6 post-update restart failed
# 7 another ProxMenux update is already running for this CT
#
# The frontend surfaces exit code + duration in a follow-up POST to
# /api/lxc-updates/<vmid>/applied so the notification event fires with
@@ -40,6 +55,41 @@ set -o pipefail
: "${TARGET:?TARGET is required}"
BACKUP="${BACKUP:-0}"
RESTART="${RESTART:-0}"
RUN_HELPER="${RUN_HELPER:-0}"
UPDATE_COMMAND="${UPDATE_COMMAND:-}"
ALLOW_HELPER_WITH_CUSTOM="${ALLOW_HELPER_WITH_CUSTOM:-0}"
DOCKER_STANDALONE_TARGETS="${DOCKER_STANDALONE_TARGETS:-}"
UPDATE_DOCKER_ENGINE="${UPDATE_DOCKER_ENGINE:-0}"
if [[ ! "$VMID" =~ ^[1-9][0-9]*$ ]]; then
echo "ERROR: VMID must be a positive integer." >&2
exit 1
fi
if [[ "$TARGET" != "os" && "$TARGET" != "app" && "$TARGET" != "both" ]]; then
echo "ERROR: TARGET must be os, app, or both." >&2
exit 4
fi
if [[ "$RUN_HELPER" != "0" && "$RUN_HELPER" != "1" ]]; then
echo "ERROR: RUN_HELPER must be 0 or 1." >&2
exit 5
fi
if [[ "$ALLOW_HELPER_WITH_CUSTOM" != "0" && "$ALLOW_HELPER_WITH_CUSTOM" != "1" ]]; then
echo "ERROR: ALLOW_HELPER_WITH_CUSTOM must be 0 or 1." >&2
exit 5
fi
if [[ "$UPDATE_DOCKER_ENGINE" != "0" && "$UPDATE_DOCKER_ENGINE" != "1" ]]; then
echo "ERROR: UPDATE_DOCKER_ENGINE must be 0 or 1." >&2
exit 5
fi
# One update per CT at a time, regardless of whether it came from the
# UI or the scheduler. The descriptor remains open for this process.
LOCK_DIR="${PROXMENUX_LOCK_DIR:-/run/lock}"
exec 9>"${LOCK_DIR}/proxmenux-lxc-update-${VMID}.lock"
if ! flock -n 9; then
echo "ERROR: another ProxMenux update is already running for CT $VMID." >&2
exit 7
fi
STARTED_AT=$(date -Iseconds)
NODE=$(hostname)
@@ -48,6 +98,7 @@ echo "Started: $STARTED_AT"
echo "Target: $TARGET"
echo "Backup: $BACKUP${BACKUP_STORAGE:+ (storage: $BACKUP_STORAGE)}"
echo "Restart: $RESTART"
echo "Helper: $RUN_HELPER"
echo
# 1) CT must exist on this node.
@@ -56,14 +107,35 @@ if ! pct list | awk 'NR>1 {print $1}' | grep -qE "^${VMID}$"; then
exit 1
fi
# 2) CT must be running for pct exec. Auto-start stopped CTs.
# 2) CT must be running for pct exec. Auto-start stopped CTs, then
# restore their original stopped state on every exit path.
STATE=$(pct status "$VMID" | awk '{print $2}')
STARTED_BY_PROXMENUX=0
restore_original_state() {
local rc=$?
trap - EXIT INT TERM
if [[ "$STARTED_BY_PROXMENUX" == "1" ]]; then
echo
echo "Restoring original state: stopping CT $VMID"
if ! pct shutdown "$VMID" --timeout 60; then
echo "ERROR: update finished but CT $VMID could not be returned to its original stopped state." >&2
if [[ "$rc" -eq 0 ]]; then
rc=6
fi
fi
fi
exit "$rc"
}
trap restore_original_state EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if [[ "$STATE" != "running" ]]; then
echo "CT is $STATE. Starting it before applying updates…"
if ! pct start "$VMID"; then
echo "ERROR: failed to start CT $VMID." >&2
exit 2
fi
STARTED_BY_PROXMENUX=1
# give the CT a moment for services to come up
sleep 3
fi
@@ -123,41 +195,55 @@ if [[ "$TARGET" == "os" || "$TARGET" == "both" ]]; then
echo
fi
# 6) Application update. Precedence:
# a) /usr/bin/update present (community-scripts convention)
# runs the community-scripts helper FROM THE HOST with CTID
# env var. Their build.func framework requires CTID + host-only
# `pveversion`, so `pct exec ... /usr/bin/update` inside the CT
# always fails ("You need to set 'CTID' variable"). We parse
# the ct/<slug>.sh URL from /usr/bin/update and re-fetch it
# here with CTID set. PHS_SILENT=1 keeps it non-interactive.
# b) UPDATE_COMMAND env var set → run it verbatim via `sh -c`
# 6) Application update. Explicit methods only:
# a) RUN_HELPER=1 + a valid /usr/bin/update wrapper
# parses the ct/<slug>.sh URL from the wrapper, canonicalises it
# to the official repository, then runs the current helper
# inside the CT with PHS_SILENT=1.
# b) UPDATE_COMMAND set → run it verbatim via `sh -c`
# inside the CT. The one intentional shell-exec-with-variable
# in ProxMenux — see header comment for threat-model rationale.
# Both can run in the same invocation: the helper first (if
# present), then the per-app custom commands.
# UPDATE_COMMAND always wins if a legacy caller also sets RUN_HELPER.
# A hostname/tag/cache guess is never executable evidence.
if [[ "$TARGET" == "app" || "$TARGET" == "both" ]]; then
APP_METHOD_RAN=0
UPDATE_URL=""
RESOLVED_SLUG=""
if pct exec "$VMID" -- test -f /usr/bin/update 2>/dev/null; then
UPDATE_URL=$(pct exec "$VMID" -- cat /usr/bin/update 2>/dev/null | grep -oE 'https?://[^"'"'"' ]+ct/[a-zA-Z0-9._-]+\.sh' | head -1)
RESOLVED_SLUG=$(echo "$UPDATE_URL" | sed -nE 's|.*/ct/([a-zA-Z0-9._-]+)\.sh$|\1|p')
if [[ -n "$UPDATE_COMMAND" && "$RUN_HELPER" == "1" && "$ALLOW_HELPER_WITH_CUSTOM" != "1" ]]; then
echo "Custom update command configured; skipping Proxmox VE Helper-Scripts updater."
RUN_HELPER=0
fi
# HELPER_SLUG env is a passthrough from the backend when the CT no
# longer carries /usr/bin/update (older installs where the file was
# removed) but the community-scripts slug is known via hostname
# match against the helpers_cache. Lets us run the same host-side
# updater without requiring the on-CT marker file.
if [[ -z "$RESOLVED_SLUG" && -n "$HELPER_SLUG" ]]; then
if [[ "$HELPER_SLUG" =~ ^[a-zA-Z0-9._-]+$ ]]; then
RESOLVED_SLUG="$HELPER_SLUG"
UPDATE_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${RESOLVED_SLUG}.sh"
else
echo "WARN: HELPER_SLUG contains invalid characters — ignored." >&2
if [[ "$UPDATE_DOCKER_ENGINE" == "1" ]]; then
echo "--- Updating Docker Engine only ---"
if ! python3 /usr/local/share/proxmenux/monitor-app/usr/bin/update_docker_engine.py \
--vmid "$VMID"; then
echo "ERROR: Docker Engine update failed." >&2
APP_FAILED=1
fi
APP_METHOD_RAN=1
echo
fi
if [[ -n "$UPDATE_URL" && -n "$RESOLVED_SLUG" ]]; then
if [[ "$RUN_HELPER" == "1" ]]; then
UPDATE_URL=""
RESOLVED_SLUG=""
if pct exec "$VMID" -- test -f /usr/bin/update 2>/dev/null; then
UPDATE_URL=$(pct exec "$VMID" -- cat /usr/bin/update 2>/dev/null | grep -oE 'https?://[^"'"'"' ]+ct/[a-zA-Z0-9._-]+\.sh' | head -1)
RESOLVED_SLUG=$(echo "$UPDATE_URL" | sed -nE 's|.*/ct/([a-zA-Z0-9._-]+)\.sh$|\1|p')
fi
case "$RESOLVED_SLUG" in
alpine|archlinux|archlinux-vm|debian|fedora|gentoo|opensuse|ubuntu)
echo "ERROR: /usr/bin/update references the base-OS helper '$RESOLVED_SLUG', not an application updater." >&2
APP_FAILED=1
RESOLVED_SLUG=""
;;
esac
if [[ -z "$RESOLVED_SLUG" ]]; then
if [[ "$APP_FAILED" -eq 0 ]]; then
echo "ERROR: RUN_HELPER=1 but /usr/bin/update contains no valid community-scripts app reference." >&2
APP_FAILED=1
fi
else
# Never execute the arbitrary URL embedded in the CT. The slug is
# constrained by the parser; fetch the canonical upstream path.
UPDATE_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${RESOLVED_SLUG}.sh"
echo "--- Running community-scripts helper (slug: $RESOLVED_SLUG) ---"
# Community-scripts' build.func in start() dispatches on
# `command -v pveversion`: present → install_script (whiptail
@@ -184,6 +270,7 @@ if [[ "$TARGET" == "app" || "$TARGET" == "both" ]]; then
fi
APP_METHOD_RAN=1
echo
fi
fi
if [[ -n "$UPDATE_COMMAND" ]]; then
echo "--- Running user-defined update command ---"
@@ -195,9 +282,27 @@ if [[ "$TARGET" == "app" || "$TARGET" == "both" ]]; then
APP_METHOD_RAN=1
echo
fi
if [[ -n "$DOCKER_STANDALONE_TARGETS" ]]; then
IFS=',' read -r -a DOCKER_TARGETS <<< "$DOCKER_STANDALONE_TARGETS"
for DOCKER_CONTAINER in "${DOCKER_TARGETS[@]}"; do
if [[ ! "$DOCKER_CONTAINER" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$ ]]; then
echo "ERROR: invalid Docker container target '$DOCKER_CONTAINER'." >&2
APP_FAILED=1
continue
fi
echo "--- Recreating standalone Docker container: $DOCKER_CONTAINER ---"
if ! python3 /usr/local/share/proxmenux/monitor-app/usr/bin/recreate_docker_container.py \
--vmid "$VMID" --container "$DOCKER_CONTAINER"; then
echo "ERROR: protected Docker recreation failed for '$DOCKER_CONTAINER'." >&2
APP_FAILED=1
fi
APP_METHOD_RAN=1
echo
done
fi
if [[ "$APP_METHOD_RAN" -eq 0 ]]; then
if [[ "$TARGET" == "app" ]]; then
echo "ERROR: TARGET=app but no update method (UPDATE_COMMAND unset AND /usr/bin/update missing) in CT $VMID." >&2
echo "ERROR: TARGET=app but no update method was explicitly selected for CT $VMID." >&2
exit 5
else
echo "No app update method available in this CT — skipping app update step."
@@ -209,7 +314,7 @@ fi
# 7) If either branch failed, abort here BEFORE the optional reboot so
# the CT stays in the pre-update state and the user can inspect it.
if (( OS_FAILED || APP_FAILED )); then
echo "=== Update FAILED — CT left running for inspection. ==="
echo "=== Update FAILED. ==="
exit 4
fi