mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 10:47:36 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 d’application",
|
||||
"releaseDelayNone": "Aucun délai d’attente",
|
||||
"releaseDelayDays": "Attendre {days} jour(s)",
|
||||
"releaseDelayHelp": "S’applique 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 n’a é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 n’est pas configuré.",
|
||||
"managedByOsPackages": "Cette application est mise à jour par l’action 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 l’onglet 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": "L’application 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 l’image et être recréés depuis leur projet déclaré.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} est géré par paquets et mis à jour par l’action 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 d’AdGuard Home se met à jour depuis sa propre interface web.",
|
||||
"openAdguard": "Ouvrir AdGuard Home",
|
||||
"updateDockerImage": "Mettre à jour l’image",
|
||||
"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 n’a été trouvée.",
|
||||
"dockerInventoryStarting": "En attente de la fin du démarrage de Docker…",
|
||||
"dockerInventoryUnavailable": "L’inventaire Docker n’a 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 d’image 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 n’est disponible.",
|
||||
"staleTarget": "Cette sélection n’est plus disponible. Retirez-la avant d’enregistrer.",
|
||||
"dockerInventoryPending": "En attente de l’inventaire 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 d’enregistrer 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 d’images 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 d’afficher 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 d’images 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 l’application",
|
||||
"searchApplications": "Rechercher des applications",
|
||||
"searchingApplications": "Recherche d’applications…",
|
||||
"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 n’enregistre 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",
|
||||
|
||||
@@ -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 dell’app",
|
||||
"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 l’aggiornamento 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 l’azione 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 l’immagine ed essere ricreati dal progetto dichiarato.",
|
||||
"dockerEngineManagedByOs": "Docker Engine {version} è gestito dai pacchetti e viene aggiornato dall’azione 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 l’avvio…",
|
||||
"dockerInventoryUnavailable": "Non è stato possibile leggere l’inventario 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 dell’inventario 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 l’aggiornamento 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 un’interfaccia web. ProxMenux non registra questi container come applicazioni LXC indipendenti.",
|
||||
"dockerPublishedAdd": "Aggiungi link",
|
||||
"dockerPublishedPort": "porta {containerPort} del container → {hostPort} nell’LXC",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Regular → Executable
+4
@@ -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"
|
||||
|
||||
+1423
-125
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+1844
-93
File diff suppressed because it is too large
Load Diff
@@ -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,37 +200,70 @@ 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:
|
||||
# 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 "ok installed" in r.stdout:
|
||||
pcie_version = r.stdout.split("|", 1)[1].strip()
|
||||
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
|
||||
if not pcie_version:
|
||||
|
||||
# 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,
|
||||
@@ -218,23 +271,34 @@ def _detect_coral_host() -> list[dict]:
|
||||
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({
|
||||
|
||||
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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 TaskWatcher—the 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',
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
+4270
-34
File diff suppressed because it is too large
Load Diff
@@ -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]+)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+136
-31
@@ -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
|
||||
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
|
||||
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 [[ "$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
|
||||
# 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"
|
||||
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
|
||||
echo "WARN: HELPER_SLUG contains invalid characters — ignored." >&2
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$UPDATE_URL" && -n "$RESOLVED_SLUG" ]]; then
|
||||
# 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
|
||||
@@ -185,6 +271,7 @@ if [[ "$TARGET" == "app" || "$TARGET" == "both" ]]; then
|
||||
APP_METHOD_RAN=1
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
if [[ -n "$UPDATE_COMMAND" ]]; then
|
||||
echo "--- Running user-defined update command ---"
|
||||
echo "\$ $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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user