diff --git a/.github/scripts/generate_app_tracking_catalog.py b/.github/scripts/generate_app_tracking_catalog.py index 7dc4eaca..9171c40b 100644 --- a/.github/scripts/generate_app_tracking_catalog.py +++ b/.github/scripts/generate_app_tracking_catalog.py @@ -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/.`` 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/.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/. 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 diff --git a/.github/workflows/update-app-tracking-hints.yml b/.github/workflows/update-app-tracking-hints.yml index 373918a3..82a1f5ab 100644 --- a/.github/workflows/update-app-tracking-hints.yml +++ b/.github/workflows/update-app-tracking-hints.yml @@ -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 diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index bec624e0..5082b33e 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -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 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 = { + 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) => { + e.currentTarget.style.display = "none" + } + if (!themed) { + return + } + return ( + <> + + + + ) +} + // 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(seed?.sidecar ?? null) const [suggestions, setSuggestions] = useState(seed?.suggestions ?? null) const [error, setError] = useState(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(null) const [busyAppId, setBusyAppId] = useState(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(null) + const [dockerTagPreviewLoading, setDockerTagPreviewLoading] = useState(false) + const [dockerTagPreviewError, setDockerTagPreviewError] = useState(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("/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() + 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 )} - {showVersions && ( -
+ {showVersions && (managed.installed_version || repo) && ( +
{managed.installed_version && (
{t("vmLxc.appEditor.installedStatus")}
-
+
{managed.installed_version} - {upToDate && ( - - )}
)} @@ -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) => + const setField = (patch: Partial) => { + 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 (
@@ -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 = { 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
+ {suggestableDockerLinks.length > 0 && ( +
+
+
+ {t("vmLxc.appEditor.dockerPublishedServicesTitle")} +
+

+ {t("vmLxc.appEditor.dockerPublishedServicesHelp")} +

+
+
+ {suggestableDockerLinks.map((link) => ( +
+ {link.logo_url && ( + + )} +
+
{link.service_name}
+
+ {link.image} · {t("vmLxc.appEditor.dockerPublishedPort", { + containerPort: link.container_port, + hostPort: link.host_port, + })} +
+
+ +
+ ))} +
+
+ )} + {/* 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
)} - {draft.ports.length === 0 && suggestable.length === 0 && ( + {draft.ports.length === 0 && suggestable.length === 0 && suggestableDockerLinks.length === 0 && (
{t("vmLxc.appEditor.noWebPorts")}
@@ -1470,9 +1763,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop maxLength={255} />
- owner/name — or bare - name for official images. ProxMenux picks the highest semver tag - matching the filter below. + {t("vmLxc.appEditor.dockerVersionedTagsHelp")}
)} @@ -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" && ( +
+ + +
+ )}
{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")}
)} + + {upstreamType === "docker_hub" && draft.docker_image?.trim() && ( +
+
+
+ {t("vmLxc.appEditor.dockerTagPreviewLabel")} +
+ {dockerTagPreview && ( + + {t("vmLxc.appEditor.dockerTagPreviewCount", { + matched: dockerTagPreview.matched_count, + scanned: dockerTagPreview.scanned_count, + })} + + )} +
+ {dockerTagPreviewLoading ? ( +
+ + {t("vmLxc.appEditor.dockerTagPreviewLoading")} +
+ ) : dockerTagPreviewError ? ( +
{dockerTagPreviewError}
+ ) : dockerTagPreview?.tags.length ? ( +
+ {dockerTagPreview.tags.map((entry) => ( + + {entry.tag}{entry.moving ? ` · ${t("vmLxc.appEditor.dockerMovingTag")}` : ""} + + ))} +
+ ) : ( +
+ {t("vmLxc.appEditor.dockerTagPreviewEmpty")} +
+ )} + {dockerTagPreview?.tags.some((entry) => entry.moving) && ( +
+ {t("vmLxc.appEditor.dockerMovingTagHelp")} +
+ )} +
+ )} ) })()} @@ -1541,6 +1901,47 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop )} + {detectorTest && ( +
+
+
+ {t("vmLxc.appEditor.detectorTestTitle")} +
+
+
+
+
+ {t("vmLxc.appEditor.installedStatus")} +
+ {detectorTest.installed.version ? ( +
{detectorTest.installed.version}
+ ) : ( +
{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}
+ )} +
+ {detectorTest.installed.method || "—"} + {detectorTest.installed.effective_regex ? ` · ${detectorTest.installed.effective_regex}` : ""} +
+
+
+
+ {t("vmLxc.appEditor.latestUpstream")} +
+ {!detectorTest.upstream.configured ? ( +
{t("vmLxc.appEditor.detectorTestNoUpstream")}
+ ) : detectorTest.upstream.version ? ( +
{detectorTest.upstream.version}
+ ) : ( +
{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}
+ )} + {detectorTest.upstream.type && ( +
{detectorTest.upstream.type}
+ )} +
+
+
+ )} + {error && (
@@ -1549,10 +1950,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop )}
- + {method && ( + + )} + +
+ {detectionNotice && ( +

+ {detectionNotice.text} +

+ )} )} @@ -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 (
{app.logo_url && ( - { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )}
@@ -1801,30 +2234,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop )}
- {/* 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 ( -
+
{st?.installed_version && (
{t("vmLxc.appEditor.installedStatus")}
-
+
{st.installed_version} - {upToDate && ( - - )}
)} @@ -1867,11 +2287,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop return (
{p.logo_url && ( - { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )}
@@ -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 && ( -
+
+ )} {/* Updates tab — LXC only, always visible so users can @@ -4288,6 +4626,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { null return ( mutate()} @@ -4479,81 +4818,252 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { Button state → color: • Purple = updates pending (Apply X, arrow icon) • Green = up to date (X, no icon, no "Apply") - Combined button surfaces only when exactly ONE - app method (helper /usr/bin/update OR a single - registered app with `update_command`) is present. - With zero the button makes no sense; with N > 1 - we can't know which app to invoke, so the user - picks individually via section-level buttons. */} + Individual actions stay with their section. The + optional reusable bulk action is configured in its + own card immediately before Options. */} {!selectedVM.update_check?.managed_oci_app && !selectedVM.update_check?.is_oci_lxc && (() => { const uc = selectedVM.update_check const hasOsUpdates = !!uc?.available - const helperExists = !!uc?.app_updater_present + const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker") + const dockerRegistered = !!dockerAppWatch + const dockerEngineInstalledVersion = selectedVM.docker_inventory?.engine_version + || dockerAppWatch?.installed_version + || "" + const dockerEngineHasUpdate = dockerAppWatch?.update_available === true + const dockerEngineUpToDate = dockerAppWatch?.update_available === false + && !!dockerEngineInstalledVersion + const dockerEditing = !!dockerAppWatch && customCmdEditingApp === dockerAppWatch.id + const dockerSavedUpdateCommand = dockerAppWatch?.update_command?.trim() || "" + const dockerHasSavedCommand = !!dockerSavedUpdateCommand + const dockerUsesIntegratedUpdater = !dockerSavedUpdateCommand + || dockerSavedUpdateCommand === canonicalDockerEngineUpdateCommand + const dockerHasCustomCommand = !dockerUsesIntegratedUpdater + const dockerEffectiveUpdateCommand = dockerSavedUpdateCommand + || canonicalDockerEngineUpdateCommand + const dockerInventoryRefreshing = selectedVM.docker_inventory?.refreshing === true + const dockerInventoryAvailable = selectedVM.docker_inventory?.available === true + const dockerImages = dockerRegistered ? (selectedVM.docker_inventory?.images || []) : [] + const dockerPending = dockerImages.filter((image) => image.update_available === true) + const helperExists = !!uc?.app_updater_present && uc?.helper_slug_source === "update_wrapper" const helperName = uc?.helper_app_name || null - const helperKnownNotUpdateable = !helperExists && !!uc?.helper_slug && !!uc?.helper_updateable_known - const helperUnlisted = !helperExists && !!uc?.helper_slug && !uc?.helper_updateable_known - const trackedApps = (selectedVM.app_watches || []).filter( - (a) => !a.managed_oci_app_id && !!a.installed_via, + const helperUsesWebUpdater = uc?.helper_slug === "adguard" + const helperInferred = !helperExists && !!uc?.helper_slug && uc?.helper_slug !== "docker" && uc?.helper_slug_source === "tag_hostname" + const helperKnownNotUpdateable = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !!uc?.helper_updateable_known + const helperUnlisted = !helperExists && !!uc?.helper_slug && uc?.helper_slug_source === "update_wrapper" && !uc?.helper_updateable_known + // Registration, version tracking and update execution + // are independent capabilities. Every saved app belongs + // in Updates; installed_via only controls whether a + // version state can be shown. + const registeredApps = (selectedVM.app_watches || []).filter( + (a) => !a.managed_oci_app_id, ) - const customCmdApps = trackedApps.filter( - (a) => !!(a.update_command && a.update_command.trim()), + const helperSectionDetected = uc?.helper_slug !== "docker" + && (helperExists || helperKnownNotUpdateable || helperUnlisted || helperInferred) + const helperMatchingApps = registeredApps.filter( + (a) => !!a.helper_slug && a.helper_slug === uc?.helper_slug, ) - // Apps eligible for a section in the unified - // card. Rules: - // • Any app with a `update_command` gets its - // own section (always). - // • Any other app gets its own section UNLESS: - // - it is the specific app the CT-wide - // helper section is already covering - // (matched by helper_slug), OR - // - its install method is dpkg/apk — those - // packages are already updated as part - // of the OS section's `apt/apk upgrade` - // run, so a dedicated "no method" notice - // is misleading (Redis, PostgreSQL, etc). - // The App-tab purple ⬆ still surfaces the - // version delta; user just clicks Apply - // OS update to pick it up. - const appSections = trackedApps.filter((a) => { + const helperOnlyApps = helperMatchingApps.filter( + (a) => !a.update_command, + ) + // Every registered app gets exactly one Updates + // section. Docker and the CT-wide helper identity use + // their specialised sections; all other registrations + // use the generic section even when installed_via is + // empty (Web Link only) or dpkg/apk is OS-managed. + const appSections = registeredApps.filter((a) => { + // Docker owns a dedicated section containing + // Engine and image lifecycles. Its command editor + // is rendered there so Docker never appears twice. + if (a.helper_slug === "docker") return false const hasCmd = !!(a.update_command && a.update_command.trim()) - if (hasCmd) return true - const isHelperOwnedApp = helperExists && !!a.helper_slug && a.helper_slug === uc?.helper_slug + const isEditing = customCmdEditingApp === a.id + if (hasCmd || isEditing) return true + const isHelperOwnedApp = helperSectionDetected + && !!a.helper_slug + && a.helper_slug === uc?.helper_slug if (isHelperOwnedApp) return false - if (a.installed_via === "dpkg" || a.installed_via === "apk") return false return true }) - const anyAppPending = - (helperExists && trackedApps.some((a) => a.update_available === true)) - || customCmdApps.some((a) => a.update_available === true) - // The combined "Apply OS + " button requires - // the app to be REGISTERED by the user AND actually - // tracked. Two independent gates: - // 1. helper_slug must match the CT's helper — a - // detected-but-not-registered app (e.g. AdGuard - // shown as "Detected" in the App tab) has no - // entry here and never triggers the button. - // 2. The registered entry must show evidence of - // tracking — installed_version present, or - // update_available defined either way. This - // guards against the catalog picker auto-filling - // helper_slug when the user picked the app only - // for its weblink and never configured updates; - // until a check has run, no combined button. - const helperRegistered = helperExists && trackedApps.some( - (a) => - !!a.helper_slug - && a.helper_slug === uc?.helper_slug - && (!!a.installed_version || a.update_available !== undefined), + const scheduledAppChoices = registeredApps.filter((app) => { + if (app.helper_slug === "docker" || app.helper_slug === "adguard") return false + if (app.update_command?.trim()) return true + return helperExists && app.helper_slug === uc?.helper_slug + }).map((app) => ({ id: `app:${app.id}`, label: app.name })) + const versionTrackedScheduleAppIds = new Set( + registeredApps + .filter((app) => !!app.installed_via && app.helper_slug !== "docker") + .map((app) => `app:${app.id}`), ) - const singleAppMethod = (helperRegistered ? 1 : 0) + customCmdApps.length === 1 - const combinedApp: { name: string; cmd: string; isHelper: boolean } | null = helperRegistered - ? { name: helperName || "application", cmd: "", isHelper: true } - : customCmdApps.length === 1 - ? { name: customCmdApps[0].name || "application", cmd: customCmdApps[0].update_command!, isHelper: false } - : null + const scheduleHasVersionTrackedApps = scheduleTargets.includes("apps") + ? versionTrackedScheduleAppIds.size > 0 + : scheduleTargets.some((target) => versionTrackedScheduleAppIds.has(target)) + const composeProjects = new Map() + for (const target of selectedVM.docker_inventory?.compose_projects || []) { + composeProjects.set(`docker-compose:${target.project}`, target) + } + const standaloneContainers = new Set() + for (const image of dockerImages) { + for (const target of image.update_targets || []) { + const id = `docker-compose:${target.project}` + if (!composeProjects.has(id)) composeProjects.set(id, target) + } + for (const container of image.standalone_containers || []) { + standaloneContainers.add(container) + } + } + const scheduleChoices = [ + { id: "os", label: t("vmLxc.scheduled.targetOptionOs") }, + ...scheduledAppChoices, + ...(dockerRegistered && selectedVM.docker_inventory?.available + ? [{ id: "docker-engine", label: t("vmLxc.scheduled.dockerEngineTarget") }] + : []), + ...Array.from(composeProjects, ([id, target]) => ({ + id, + label: t("vmLxc.scheduled.dockerComposeTarget", { project: target.project }), + })), + ...Array.from(standaloneContainers, (container) => ({ + id: `docker-container:${container}`, + label: t("vmLxc.scheduled.dockerContainerTarget", { container }), + })), + ] + const scheduleChoiceChecked = (id: string) => + scheduleTargets.includes(id) || (id.startsWith("app:") && scheduleTargets.includes("apps")) + const toggleScheduleChoice = (id: string, checked: boolean) => { + let next = scheduleTargets.includes("apps") + ? [ + ...scheduleTargets.filter((value) => value !== "apps"), + ...scheduledAppChoices.map((choice) => choice.id), + ] + : [...scheduleTargets] + next = checked + ? Array.from(new Set([...next, id])) + : next.filter((value) => value !== id) + setScheduleTargets(next) + const hasOs = next.includes("os") + const hasApp = next.some((value) => value !== "os") + setScheduleTarget(hasOs && hasApp ? "both" : hasOs ? "os" : "app") + } + const selectedScheduleLabels = scheduleChoices + .filter((choice) => scheduleChoiceChecked(choice.id)) + .map((choice) => choice.label) + const bulkAppChoices = registeredApps.filter((app) => { + if (app.helper_slug === "docker") return false + if (app.update_command?.trim()) return true + return helperExists + && app.helper_slug === uc?.helper_slug + && app.helper_slug !== "adguard" + }).map((app) => { + const webLinkLogo = (app.ports || []) + .find((port) => Boolean(port.logo_url?.trim())) + ?.logo_url?.trim() + return { + id: `app:${app.id}`, + label: app.name || t("vmLxc.updates.applicationDefaultName"), + detail: "", + logoUrl: webLinkLogo || app.logo_url?.trim() || "", + } + }) + const dockerUpdateUnits = dockerRegistered + ? (selectedVM.docker_inventory?.update_units || []) + : [] + const bulkActionChoices = [ + ...bulkAppChoices, + ...(dockerRegistered + ? [{ + id: "docker-engine", + label: "Docker Engine", + detail: "", + logoUrl: "", + }] + : []), + ...dockerUpdateUnits.map((unit) => ({ + id: unit.id, + label: unit.display_name || unit.primary_reference || unit.primary_service || unit.id, + detail: (unit.dependent_services || []).length + ? t("vmLxc.bulkUpdate.includesDependencies", { + names: (unit.dependent_services || []).join(", "), + }) + : "", + logoUrl: unit.logo_url || "", + })), + ] + const bulkChoices = [{ + id: "os", + label: t("vmLxc.bulkUpdate.osTarget"), + detail: t("vmLxc.bulkUpdate.osRequired"), + logoUrl: "", + }, ...bulkActionChoices] + const bulkChoiceIds = new Set(bulkChoices.map((choice) => choice.id)) + const bulkActionAppIds = new Set(bulkAppChoices.map((choice) => choice.id)) + const bulkUnavailableApps = registeredApps.filter((app) => ( + app.helper_slug !== "docker" + && !bulkActionAppIds.has(`app:${app.id}`) + )) + const pendingDockerBulkTargets = bulkTargets.filter((target) => ( + target.startsWith("docker-unit:") + && !bulkChoiceIds.has(target) + && !dockerInventoryAvailable + )) + const staleBulkTargets = bulkTargets.filter((target) => ( + target !== "os" + && !bulkChoiceIds.has(target) + && !pendingDockerBulkTargets.includes(target) + )) + const selectedBulkLabels = Array.from(new Set([ + ...bulkChoices + .filter((choice) => bulkTargets.includes(choice.id)) + .map((choice) => choice.label), + ...(pendingDockerBulkTargets.length + ? [t("vmLxc.bulkUpdate.dockerInventoryPending")] + : []), + ...staleBulkTargets.map((target) => target.startsWith("docker-unit:") + ? t("vmLxc.bulkUpdate.missingDockerTarget") + : target), + ])) + // A configured bulk action inherits the same + // three-state contract as each individual updater: + // pending when at least one selected target has a + // confirmed update, up to date only when every target + // has been checked and is current, otherwise unknown. + const selectedBulkUpdateStates = bulkTargets.map((target) => { + if (target === "os") { + if (!uc || uc.error) return null + return hasOsUpdates + } + if (target === "docker-engine") { + if (dockerEngineHasUpdate) return true + if (dockerEngineUpToDate) return false + return null + } + if (target.startsWith("app:")) { + const app = registeredApps.find((entry) => `app:${entry.id}` === target) + if (!app || !app.installed_via) return null + if (app.update_available === true) return true + if (app.update_available === false && app.installed_version) return false + return null + } + if (target.startsWith("docker-unit:")) { + if (dockerInventoryRefreshing || !dockerInventoryAvailable) return null + const unit = dockerUpdateUnits.find((entry) => entry.id === target) + if (unit?.update_available === true) return true + if (unit?.update_available === false) return false + return null + } + return null + }) + const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true) + const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0 + && selectedBulkUpdateStates.every((state) => state === false) + const toggleBulkChoice = (id: string, checked: boolean) => { + if (id === "os") return + setBulkTargets((current) => checked + ? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id])) + : current.filter((value) => value !== id)) + } const pendingBtnCls = "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300" + const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground" return ( <> @@ -4629,6 +5139,293 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
+ {/* Docker has three independent lifecycles: + Engine packages, Compose services and + standalone containers. Keep all three in + one registered-app section while exposing + a concrete action for each target. */} + {dockerRegistered && dockerAppWatch && ( +
+
+
+ +

+ {t("vmLxc.updates.dockerAppTitle")} +

+
+ {!dockerEditing && ( + + )} +
+ {dockerAppWatch.checked_at && !dockerEditing && ( +
+ {t("vmLxc.updates.lastCheckedPrefix")} {new Date(dockerAppWatch.checked_at).toLocaleString()} +
+ )} + {dockerEditing ? ( +
+
+ +