"use client" /** * LxcAppPanel — Body of the "App" tab in the LXC modal. * * Handles a LIST of apps per CT (one CT can host several services * — e.g. Frigate on port 5000 + go2rtc on 1984, or Docker + two * containerised apps). Each app has: * • an install method (dpkg / apk / file / binary / docker) * • an optional GitHub repo for upstream version tracking * • a list of ports, each with a description and web path * * 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 → * Secure Gateway. */ 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, Bell, BellOff, Search, } from "lucide-react" import { Card, CardContent } from "./ui/card" import { Button } from "./ui/button" import { Input } from "./ui/input" 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, setLxcAppsCached } from "../lib/lxc-apps-cache" import { useT } from "@/lib/i18n/provider" // installed_via is optional now — an empty value means "register only, // no version tracking, no warnings, just a clickable link". Docker // apps and casual "just want a link" registrations use this default. type InstalledVia = "" | "dpkg" | "apk" | "file" | "binary" | "python_dist" | "docker_label" | "docker_exec" | "command" | "manual" type GithubSource = "releases" | "tags" interface PortEntry { port: number | "" description?: string scheme?: "http" | "https" web_path?: string logo_url?: string } interface AppConfig { name: string installed_via?: 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 source discriminator + fields. When `upstream_type` is // "github" (default when `repo` is set) the classic repo / // github_source / tag_regex fields drive the check. "http_json" and // "docker_hub" open two new source types validated separately on // the backend. upstream_type?: "github" | "http_json" | "docker_hub" | "" repo?: string github_source?: GithubSource upstream_url?: string upstream_json_path?: string docker_image?: string tag_regex?: string ports: PortEntry[] 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. notifications_enabled?: boolean // Per-app opt-out for the CT's aggregate updates badge (default // false = counted). Independent from `notifications_enabled`. exclude_from_badge?: boolean } interface DetectedApp { slug: string name: string logo_url?: string | null default_ports?: number[] tracking_suggestion?: TrackingSuggestion | null } 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 } interface AppEntry extends AppConfig { id: string state?: AppState created_at?: string } interface SidecarResponse { vmid: number apps: AppEntry[] dismissed_slugs?: string[] created_at?: string 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: 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[] web_path_hint: string | null tracking_suggestion?: TrackingSuggestion | null default_ports?: number[] logo_url?: string | null extras?: DetectedApp[] docker_web_links?: DockerWebLinkSuggestion[] } // Compact catalog entry — one row for every registerable app the // picker can offer. Fetched once from /api/apps/catalog on panel // mount, filtered client-side while the user types. interface CatalogEntry { slug: string name: string logo: string default_port: number has_tracking: boolean } // Full detail for a picked catalog entry — server merges catalog // metadata + curated tracking_suggestion (when available) so the // editor can pre-fill every field in one round-trip. interface CatalogDetail { slug: string name: string logo_url: string | null website: string default_ports: number[] tracking_suggestion?: TrackingSuggestion | null } interface ManagedAppInfo { managed_oci_app_id: string name: string installed_version?: string | null latest_version?: string | null update_available?: boolean | null checked_at?: string | null error?: string | null } interface Props { vmid: number ctIp?: string | null onChange?: () => void managed?: ManagedAppInfo | null // Optional seed payload from the parent's cross-open ref cache. When // supplied, the panel renders with real content on the very first // frame and only revalidates silently in the background — no // "Loading applications…" flash on tab switch or modal reopen. Must // include BOTH sidecar and suggestions — the panel's empty-state and // detected-chip strip both depend on suggestions, so seeding sidecar // alone briefly flashes "no apps registered" until suggestions // arrives from the network. initialData?: { sidecar: SidecarResponse; suggestions: Suggestions | null } | null } const EMPTY_APP: AppConfig = { name: "", installed_via: "", // no tracking by default — just a link package: "", upstream_type: "", repo: "", github_source: "releases", upstream_url: "", upstream_json_path: "", docker_image: "", tag_regex: "v?(\\d+\\.\\d+\\.\\d+)", ports: [], 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. const HTTPS_HINT_PORTS = new Set([443, 4443, 8443, 9443]) const defaultSchemeFor = (port: number | ""): "http" | "https" => HTTPS_HINT_PORTS.has(Number(port)) ? "https" : "http" function buildWebUrl(ip: string | undefined | null, port: number | "", scheme?: "http" | "https") { if (!ip || ip === "DHCP" || !port) return null return `${scheme || defaultSchemeFor(port)}://${ip}:${port}` } // Suggest a dpkg/apk package name from a friendly app name — lowercase, // spaces and slashes to hyphens, drop punctuation. Only used as a // placeholder / auto-fill; user can always override. function suggestPackageName(name: string) { return name .trim() .toLowerCase() .replace(/[\s/]+/g, "-") .replace(/[^a-z0-9._+@:-]/g, "") .replace(/-{2,}/g, "-") .replace(/^-+|-+$/g, "") } export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) { const t = useT() // Seed from `initialData` first, then fall back to the shared cache // module. Together those two sources cover every reopen scenario // without flashing a spinner — see lxc-apps-cache.ts for the dedup // logic that also keeps concurrent fetches from racing. const seed = initialData ?? getLxcAppsCached(vmid) ?? null const [loading, setLoading] = useState(!seed) 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 // editing an app that already has installed_via set, or when the // user clicked Register on an auto-detected chip whose hint carries // tracking metadata — the user sees the fields we auto-filled and // can tweak or opt out before saving. const [showAdvanced, setShowAdvanced] = useState(false) // Catalog picker: 700+ apps fetched once from /api/apps/catalog and // filtered client-side while the user types in the Name input. The // dropdown shows top 20 matches. Selecting one calls the detail // endpoter to seed name / logo / ports / tracking_suggestion at once. const [catalog, setCatalog] = useState([]) const [pickerOpen, setPickerOpen] = useState(false) // "Register a different app" browse panel: when the user has hidden // some detections we surface them here with a Restore button before // falling through to the blank-form path. If there's nothing to // 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; detector checks remain available after enabling Edit. // Toggled from a single button next to // "Add another application". const [editMode, setEditMode] = useState(false) const load = useCallback(async () => { if (managed) { setLoading(false); return } // Only show the spinner on cold loads. When we came in seeded via // `initialData` or the shared cache, the sidecar is already // populated and the user shouldn't see a flash of "Loading…" // while we revalidate. if (!sidecar) setLoading(true) setError(null) try { // `fetchLxcApps` bundles sidecar + suggestions in one shared // in-flight promise. If the parent already fired this fetch on // modal open (see prefetchVM / handleVMClick in // virtual-machines.tsx), we await the SAME promise instead of // duplicating the request against the backend — this eliminates // the "Loading applications…" flash that used to show while a // second, racing fetch caught up. const bundle = await fetchLxcApps(vmid) if (bundle) { setSidecar(bundle.sidecar) setSuggestions(bundle.suggestions) } } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.loadFailed")) } finally { setLoading(false) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [vmid, managed]) 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 // panel used to render them in English regardless of locale. Match // the two shapes the backend produces today and fall back to the // original string so unknown errors still show something useful. const localizeUpstreamError = (msg: string | null | undefined): string => { if (!msg) return "" const trimmed = msg.trim() const lower = trimmed.toLowerCase() if (lower.startsWith("network error:")) { const detail = trimmed.slice("network error:".length).trim() if (detail.toLowerCase().includes("timed out") || detail.toLowerCase().includes("timeout")) { return t("vmLxc.appEditor.upstreamErrorTimeout") } return t("vmLxc.appEditor.upstreamErrorNetwork", { detail }) } return msg } 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") .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. // Rules of Hooks: `useMemo` after an `if (loading) return …` // trips React error #310 the moment `loading` flips false. const apps = sidecar?.apps || [] // Unified detection list — primary community-scripts install + // every other app whose install signature was found on the CT // (`extras[]` from the backend). Both use the same DetectedApp // shape so the empty state renders them uniformly. const detectedList: DetectedApp[] = useMemo(() => { if (!suggestions) return [] const out: DetectedApp[] = [] if (suggestions.helper_slug && suggestions.name_suggestion) { out.push({ slug: suggestions.helper_slug, name: suggestions.name_suggestion, logo_url: suggestions.logo_url, default_ports: suggestions.default_ports, tracking_suggestion: suggestions.tracking_suggestion, }) } const seen = new Set(out.map((d) => d.slug)) for (const e of suggestions.extras || []) { if (!seen.has(e.slug)) { out.push(e) seen.add(e.slug) } } return out }, [suggestions]) // Registered slugs — used to filter the detection list down to // what the user hasn't already registered on this CT. const registeredSlugs = useMemo( () => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]), [apps], ) // Dismissed slugs — persisted in the sidecar. Chips the user // explicitly hid via the ✕ button stay hidden across reloads until // they register the app (which also un-dismisses implicitly). const dismissedSlugs = useMemo( () => new Set(sidecar?.dismissed_slugs || []), [sidecar], ) const visibleDetected = detectedList.filter( (d) => !registeredSlugs.has(d.slug) && !dismissedSlugs.has(d.slug), ) // Alias for pre-existing consumers (post-registration chip strip). const unregisteredDetected = visibleDetected // Detections the user hid and could restore from the Register-a- // 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 // straight to the blank editor. const openBrowseOrEditor = () => { if (hiddenDetections.length > 0) setBrowseOpen(true) else openEditor() } const openEditor = useCallback(async ( existing?: AppEntry, opts?: { withTracking?: boolean, preset?: DetectedApp }, ) => { let seed: AppConfig if (existing) { seed = { name: existing.name, installed_via: (existing.installed_via as InstalledVia) || "", package: existing.package || "", file_path: existing.file_path || "", file_regex: existing.file_regex || "", binary_path: existing.binary_path || "", binary_args: existing.binary_args ? [...existing.binary_args] : [], python_path: existing.python_path || "", distribution: existing.distribution || "", container_name: existing.container_name || "", label: existing.label || "", command_argv: existing.command_argv ? [...existing.command_argv] : [], installed_version: existing.installed_version || "", installed_regex: existing.installed_regex || "", upstream_type: existing.upstream_type || (existing.repo ? "github" : ""), repo: existing.repo || "", github_source: existing.github_source || "releases", upstream_url: existing.upstream_url || "", upstream_json_path: existing.upstream_json_path || "", docker_image: existing.docker_image || "", tag_regex: existing.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)", ports: existing.ports?.length ? existing.ports.map((p) => ({ ...p })) : [], 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, } // Editing an existing app: expand Advanced when tracking is on setShowAdvanced(!!seed.installed_via) } else { seed = { ...EMPTY_APP, ports: [] } let s = suggestions if (!s) { try { s = await fetchApi(`/api/vms/${vmid}/apps/suggestions`) setSuggestions(s) } catch { /* non-fatal */ } } // Preset path: a chip in the empty state (primary OR extra) was // clicked. Seed EVERYTHING from the preset so this works // regardless of whether it's the first or Nth app on the CT. // Primary detection is `{...suggestions}`-shaped, an extra is // `DetectedApp`-shaped — both carry name/logo/ports/tracking. if (opts?.preset) { const p = opts.preset seed.name = p.name seed.logo_url = p.logo_url || "" seed.helper_slug = p.slug // 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), web_path: s?.web_path_hint || "", })) } if (opts.withTracking && p.tracking_suggestion) { const t = p.tracking_suggestion seed = { ...seed, installed_via: t.installed_via, package: t.package || "", 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", upstream_url: (t as any).upstream_url || "", upstream_json_path: (t as any).upstream_json_path || "", docker_image: (t as any).docker_image || "", tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)", } setShowAdvanced(true) } else { setShowAdvanced(false) } } else { // No preset (bare "+ Register application"): start empty so // the user types name/ports/logo for a custom app the auto- // detector doesn't know about. setShowAdvanced(false) } } setEditing({ appId: existing?.id || null, draft: seed }) setDetectorTest(null) setError(null) }, [suggestions, vmid, sidecar]) const closeEditor = () => { setEditing(null) setDetectorTest(null) setError(null) } const save = async () => { if (!editing) return setSaving(true) setError(null) try { const url = editing.appId ? `/api/vms/${vmid}/apps/${editing.appId}` : `/api/vms/${vmid}/apps` const method = editing.appId ? "PUT" : "POST" const r: SidecarResponse & { error?: string } = await fetchApi(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(editing.draft), }) if ((r as any).error) throw new Error((r as any).error) setSidecar(r) setLxcAppsCached(vmid, r, suggestions) setEditing(null) onChange?.() } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.saveFailed")) } finally { setSaving(false) } } 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) try { const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/${appId}/check`, { method: "POST", }) setSidecar(r) setLxcAppsCached(vmid, r, suggestions) onChange?.() } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.checkFailed")) } finally { setBusyAppId(null) } } // Silence / re-enable `app_update_available` for this specific // app. Full-record PUT because the backend replaces the whole // config on update — omit a field and it disappears. We hydrate // from the current app entry, flip the flag, and post it back. const toggleAppNotifications = async (app: AppEntry) => { setBusyAppId(app.id) setError(null) try { const { id: _id, state: _state, created_at: _created, ...rest } = app const nextEnabled = app.notifications_enabled === false const payload = { ...rest, notifications_enabled: nextEnabled } const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/${app.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) setSidecar(r) setLxcAppsCached(vmid, r, suggestions) onChange?.() } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.saveFailed")) } finally { setBusyAppId(null) } } const removeOne = async (appId: string) => { if (!confirm("Remove this application from the CT's App tab?")) return setBusyAppId(appId) setError(null) try { 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")) } finally { setBusyAppId(null) } } // Hide an auto-detected chip. Optimistic UI: update the local // sidecar state immediately so the chip disappears without // waiting for the round-trip, then persist to the server. If the // POST fails, reload from server to resync. const dismissDetection = async (slug: string, name: string) => { if (!confirm(t("vmLxc.appEditor.confirmHide", { name }))) return setSidecar((prev) => prev ? { ...prev, dismissed_slugs: [...(prev.dismissed_slugs || []), slug] } : prev, ) try { const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/dismiss`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, dismissed: true }), }) setSidecar(r) setLxcAppsCached(vmid, r, suggestions) } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.dismissFailed")) await load() // resync on failure } } // Un-hide a previously dismissed slug. Used from the "Register a // different app" panel. Optimistically drops the slug from local // dismissed_slugs so the chip re-appears in the main list, then // persists. const restoreDetection = async (slug: string) => { setSidecar((prev) => prev ? { ...prev, dismissed_slugs: (prev.dismissed_slugs || []).filter((s) => s !== slug) } : prev, ) try { const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/dismiss`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, dismissed: false }), }) setSidecar(r) setLxcAppsCached(vmid, r, suggestions) } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.restoreFailed")) await load() } } // ── Managed CT (Secure Gateway etc.) ────────────────────────── // Mirrors the visual identity of a regular app card so managed OCI // apps sit next to user-registered apps without a jarring style // shift. Version data comes from managed_installs.update_check // (already tracked by oci_manager — same source as the Security → // Secure Gateway page). No footer: no Edit, no Check, no Remove — // the whole lifecycle lives in Security → Secure Gateway. if (managed) { // Currently the only OCI managed app is Secure Gateway (Tailscale // in an Alpine CT). When we add more OCI apps we'll swap this to // a lookup keyed on managed_oci_app_id → catalog metadata. const isSecureGateway = managed.managed_oci_app_id === "secure-gateway" const displayName = isSecureGateway ? "Secure Gateway" : (managed.name || t("vmLxc.appEditor.managedApp")) const displaySubtitle = isSecureGateway ? "Tailscale VPN Gateway" : "" // Two variants — the selfh.st mark (dark logo on light bg) reads // better in light mode; the homarr-labs "-light" variant (light // logo on dark bg) reads better in dark mode. Both are rendered // and Tailwind's dark: class picks which one is visible. const upstreamLogoLightUrl = isSecureGateway ? "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/tailscale.webp" : "" const upstreamLogoDarkUrl = isSecureGateway ? "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/webp/tailscale-light.webp" : "" const upstreamName = isSecureGateway ? "Tailscale" : "" 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 showVersions = !!(managed.installed_version || managed.latest_version || repo) return (
{/* Block 1 — Secure Gateway identity (the ProxMenux product). Big shield, title, catalog subtitle. */}

{displayName}

{displaySubtitle && (
{displaySubtitle}
)}
{/* Block 2 — Underlying engine (Tailscale). Same visual pattern as a regular app card so it's clear this is what version tracking is anchored to. Repo link goes here (top-right on desktop / stacked on mobile) because the repo is the engine's, not Secure Gateway's. */} {upstreamName && (
{upstreamLogoLightUrl || upstreamLogoDarkUrl ? ( <> {upstreamLogoLightUrl && ( { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )} {upstreamLogoDarkUrl && ( { (e.currentTarget as HTMLImageElement).style.display = "none" }} /> )} ) : (
)}
{upstreamName}
{methodLine}
{managed.checked_at && (
{t("vmLxc.appEditor.checkedAt", { date: new Date(managed.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" }) })}
)} {repo && ( {repo} )}
{repo && ( {repo} )}
)} {showVersions && (managed.installed_version || repo) && (
{managed.installed_version && (
{t("vmLxc.appEditor.installedStatus")}
{managed.installed_version}
)} {repo && (
{t("vmLxc.appEditor.latestUpstream")}
{managed.latest_version || {t("vmLxc.appEditor.checkingStatus")}} {hasUpdate && managed.latest_version && ( )}
)}
)} {managed.error && (
{localizeUpstreamError(managed.error)}
)} {/* Managed banner — green translucent badge signalling this CT's lifecycle is owned by ProxMenux (not user CRUD). */}
{t("vmLxc.appEditor.installedManaged")}
) } if (loading) { return (
{t("vmLxc.appEditor.loadingApplications")}
) } // ── Editor ───────────────────────────────────────────────────── if (editing) { const draft = editing.draft const method = draft.installed_via || "" const isPackaged = method === "dpkg" || method === "apk" 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, // adguardhome, portainer-ce). The user can still override. const setName = (name: string) => { const patch: Partial = { name } if (isPackaged && !draft.package?.trim()) { patch.package = suggestPackageName(name) } setField(patch) } const setPort = (i: number, patch: Partial) => { const ports = draft.ports.map((p, idx) => (idx === i ? { ...p, ...patch } : p)) setField({ ports }) } // "Add port" adds an EMPTY row for manual entry. Detected chips // ("+5000", "+1984"…) add the port directly with the port // pre-filled — no need for the user to open a row first. const addEmptyPort = () => setField({ ports: [...draft.ports, { port: "", description: "", scheme: "http" }] }) const addDetectedPort = (port: number) => { const scheme = defaultSchemeFor(port) // If the last row is still empty, fill it instead of appending // a duplicate. Avoids the two-lines-appear bug. const last = draft.ports[draft.ports.length - 1] if (last && last.port === "" && !last.description) { const ports = [...draft.ports] ports[ports.length - 1] = { port, description: "", scheme } setField({ ports }) } else { 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 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 (
{/* Editor card — raised to `bg-accent` (matches the tone clickable cards get on hover) so the `bg-background` inputs sit clearly recessed against it. The three descendant selectors push every Input / Textarea / SelectTrigger (role="combobox") under this card down to `bg-background` in one shot, so future fields inherit the sunken look without per-input styling. Reverts to `bg-card/50` automatically because this render branch only fires when `editing !== null`. */}
{ setName(e.target.value); setPickerOpen(true) }} onFocus={() => setPickerOpen(true)} onBlur={() => setTimeout(() => setPickerOpen(false), 150)} placeholder={suggestions?.name_suggestion || t("vmLxc.appEditor.nameSearchPlaceholder")} maxLength={64} autoComplete="off" /> {/* Catalog picker dropdown — filters the 700+ helpers_cache entries by name substring while the user types. Top 20 matches shown. Click one to auto-fill name / logo / ports / tracking hint in one shot. Empty query with the input focused shows a "start typing" hint. */} {pickerOpen && catalog.length > 0 && (() => { const q = (draft.name || "").trim().toLowerCase() if (!q) return null const matches = catalog .filter((c) => c.name.toLowerCase().includes(q) || c.slug.toLowerCase().includes(q)) .slice(0, 20) if (!matches.length) return null return (
{matches.length === 20 ? t("vmLxc.appEditor.top20Matches") : t("vmLxc.appEditor.matchCount", { count: matches.length })}
{matches.map((c) => ( ))}
) })()}
{/* App-level logo URL — optional. Auto-filled from the catalog for helper-scripts installs, blank otherwise. For a custom app the user can paste a URL (typically from https://selfh.st/icons); empty → no logo in the app card header. */}
setField({ logo_url: e.target.value })} placeholder={t("vmLxc.appEditor.portLogoPlaceholder")} maxLength={512} className="text-sm mt-2 font-mono" />
{/* Web links — each port becomes a clickable link (built as http[s]://:). Detected chips add the port directly (no need to first open an empty row). */}
{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 or adds a new one; never duplicates. */} {suggestable.length > 0 && (
{t("vmLxc.appEditor.detectedPorts")} {suggestable.map((p) => ( ))}
)} {draft.ports.length === 0 && suggestable.length === 0 && suggestableDockerLinks.length === 0 && (
{t("vmLxc.appEditor.noWebPorts")}
)}
{draft.ports.map((entry, i) => (
setPort(i, { port: e.target.value ? Number(e.target.value) : "" })} placeholder={t("vmLxc.appEditor.portPortPlaceholder")} min={1} max={65535} className="text-sm" /> setPort(i, { description: e.target.value })} placeholder={t("vmLxc.appEditor.portDescriptionPlaceholder")} maxLength={64} className="text-sm" /> {/* Per-link logo URL — spans cols 1-3 so its right edge lines up with the description input above (never covers the trash column). */} setPort(i, { logo_url: e.target.value })} placeholder={t("vmLxc.appEditor.portLogoLabel")} maxLength={512} className="col-start-1 col-end-4 text-xs font-mono h-8 opacity-70 focus:opacity-100" type="url" />
))}
{/* ── Advanced (Version tracking, optional) ────────── Collapsed by default so casual users never see the technical fields. Auto-expanded when editing an app that already has tracking configured, or when the user registered from an auto-detected chip whose hint carried tracking metadata. */}
{showAdvanced && (

{t("vmLxc.appEditor.trackHelp")}

{isPackaged && (
setField({ package: e.target.value })} placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"} />
Auto-filled from Name. Verify with{" "} {method === "dpkg" ? "dpkg -l | grep " : "apk info | grep "} {" "} inside the CT.
)} {method === "binary" && (
setField({ binary_path: e.target.value })} placeholder={t("vmLxc.appEditor.binaryPathPlaceholder")} className="font-mono text-xs" />
Absolute path. Find it with{" "} which <app> or{" "} systemctl show <service> -p ExecStart.
)}
{method === "file" && (
setField({ file_path: e.target.value })} placeholder="/opt/app/VERSION" className="font-mono text-xs" />
setField({ file_regex: e.target.value })} placeholder={t("vmLxc.appEditor.regexPlaceholderVersion")} className="font-mono text-xs" />
)} {method === "python_dist" && (
setField({ python_path: e.target.value })} placeholder={t("vmLxc.appEditor.pythonInterpreterPlaceholder")} className="font-mono text-xs" />
setField({ distribution: e.target.value })} placeholder={t("vmLxc.appEditor.pipDistPlaceholder")} className="font-mono text-xs" />
)} {method === "docker_label" && (
setField({ container_name: e.target.value })} placeholder={t("vmLxc.appEditor.containerNamePlaceholder")} className="font-mono text-xs" />
setField({ label: e.target.value })} placeholder={t("vmLxc.appEditor.ociLabelPlaceholder")} className="font-mono text-xs" />
)} {method === "docker_exec" && (
setField({ container_name: e.target.value })} placeholder={t("vmLxc.appEditor.containerNamePlaceholder")} className="font-mono text-xs" />
setField({ binary_path: e.target.value })} placeholder={t("vmLxc.appEditor.binaryPathBarePlaceholder")} className="font-mono text-xs" />
setField({ binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean), })} placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")} className="font-mono text-xs" />
{t("vmLxc.appEditor.binaryArgsHintPrefix")} --version. {t("vmLxc.appEditor.binaryArgsHintGrafana")} server, -v.
)} {method === "command" && (
setField({ command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean), })} placeholder={t("vmLxc.appEditor.commandPlaceholder")} className="font-mono text-xs" />
{t("vmLxc.appEditor.commandSafetyHelp")}
setField({ installed_regex: e.target.value })} placeholder={t("vmLxc.appEditor.tagRegexBare")} className="font-mono text-xs mt-1" />
)} {method === "manual" && (
setField({ installed_version: e.target.value })} placeholder="1.2.3" maxLength={64} className="font-mono text-xs" />
{t("vmLxc.appEditor.manualVersionHelp")}
)} {method && (() => { // Upstream source selector — 3 methods (github, // http_json, docker_hub). Legacy sidecars with a // `repo` set but no `upstream_type` default to // github so the classic behaviour keeps working // until the user re-saves. const upstreamType = draft.upstream_type || (draft.repo ? "github" : "") const setUpstream = (t: "" | "github" | "http_json" | "docker_hub") => { // Clear other-type fields when switching so the // backend doesn't receive stale data. const patch: Partial = { upstream_type: t } if (t !== "github") { patch.repo = "" patch.github_source = "releases" } if (t !== "http_json") { patch.upstream_url = "" patch.upstream_json_path = "" } if (t !== "docker_hub") { patch.docker_image = "" } if (!t) patch.tag_regex = "" setField(patch) } return ( <>
{t("vmLxc.appEditor.upstreamHelp")}
{upstreamType === "github" && (
setField({ repo: e.target.value })} placeholder={t("vmLxc.appEditor.githubRepoPlaceholder")} />
{t("vmLxc.appEditor.githubRepoHelp")}
)} {upstreamType === "http_json" && (
setField({ upstream_url: e.target.value })} placeholder={t("vmLxc.appEditor.endpointUrlPlaceholder")} className="font-mono text-xs" maxLength={512} />
{t("vmLxc.appEditor.httpJsonHelp")}
setField({ upstream_json_path: e.target.value })} placeholder={t("vmLxc.appEditor.jsonPathPlaceholder")} className="font-mono text-xs" maxLength={128} />
{t("vmLxc.appEditor.jsonPathHelp")}
)} {upstreamType === "docker_hub" && (
setField({ docker_image: e.target.value })} placeholder={t("vmLxc.appEditor.dockerImagePlaceholder")} className="font-mono text-xs" maxLength={255} />
{t("vmLxc.appEditor.dockerVersionedTagsHelp")}
)} {upstreamType && (
setField({ tag_regex: e.target.value })} 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" && 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")}
)}
)} ) })()}
)}
{/* Per-app notification opt-out. Only makes sense for apps with tracking configured — without an upstream source there's no `app_update_available` event to mute. Default is ON (checkbox checked); the bell toggle on each card is a shortcut to the same field. The second checkbox below controls the CT's aggregate updates badge independently — a user may want the outbound notification but hide the counter (or the reverse). */} {method && (
)} {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 && (
{error}
)}
{method && ( )}
) } // Restore chip — same visual shell as detection chip but the // action switches from Register/Hide to Restore. Used inside the // Register-a-different-app panel when hidden slugs exist. const renderRestoreChip = (d: DetectedApp) => (
{d.logo_url && ( )}
{d.name}
{t("vmLxc.appEditor.hiddenBadge")}
) // Uniform detection chip used in every context — empty state, // post-registration "also detected" strip, and the Register-a- // different-app panel. Actions layout responsive: // • Desktop (sm+): Register + Hide side-by-side, both labeled // • Mobile: same row, 3/4 Register (label+icon) + 1/4 Hide // (eye icon only inside a red-translucent button) // // Single "Register" button covers both paths — with or without // tracking hint — the editor opens pre-filled with whatever data // we have, and the user can adjust in Advanced. const renderDetectionChip = (d: DetectedApp) => (
{d.logo_url && ( )}
{d.name}
{d.tracking_suggestion?.detector_verified ? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" }) : t("vmLxc.appEditor.detectedInContainer")}
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
{t("vmLxc.appEditor.legacyDetectorUsed")}
)}
) return (
{/* Browse panel — surfaces hidden detections with Restore before falling through to the blank-form path. Rendered before app cards / empty state so it takes precedence when open. Closes automatically once all hidden slugs are restored (nothing left to show → back to normal flow). */} {browseOpen && (

{t("vmLxc.appEditor.registerDifferent")}

{t(hiddenDetections.length === 1 ? "vmLxc.appEditor.hiddenDetectionsHelpSingular" : "vmLxc.appEditor.hiddenDetectionsHelpPlural", { count: hiddenDetections.length })}

{hiddenDetections.length > 0 && (
{hiddenDetections.map(renderRestoreChip)}
)}
)} {/* Empty state — always uniform chip list regardless of how many detections there are (0, 1, or many). Below the chips, a single "Register a different app" button lets the user add something the auto-detector doesn't know about. */} {apps.length === 0 && (

{t("vmLxc.appEditor.noAppsTitle")}

{t("vmLxc.appEditor.noAppsBody")}

{visibleDetected.length > 0 && (
{visibleDetected.map(renderDetectionChip)}
)}
{detectionNotice && (

{detectionNotice.text}

)}
)} {/* App cards */} {apps.map((app) => { const st = app.state // 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 return (
{app.logo_url && ( )}

{app.name}

{tracking && (
{app.installed_via === "dpkg" && app.package && <>dpkg · {app.package}} {app.installed_via === "apk" && app.package && <>apk · {app.package}} {app.installed_via === "file" && app.file_path && <>file · {app.file_path}} {app.installed_via === "binary" && app.binary_path && <>binary · {app.binary_path}}
)} {tracking && st?.checked_at && (
{t("vmLxc.appEditor.checkedAt", { date: new Date(st.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" }) })}
)} {/* Mobile-only repo link: falls into the metadata stack below Checked, full-width so long repo names wrap cleanly instead of competing with the top-right on narrow screens. */} {app.repo && tracking && ( {app.repo} )}
{/* Desktop-only repo link: same row as the title on md+, hidden on mobile where the stacked variant above handles it. */} {app.repo && tracking && ( {app.repo} )}
{(() => { 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}
)} {hasUpstream && (
{t("vmLxc.appEditor.latestUpstream")}
{st?.latest_version || {t("vmLxc.appEditor.checkingStatus")}} {hasUpdate && st?.latest_version && ( )}
)}
) })()} {tracking && st?.error && (
{localizeUpstreamError(st.error)}
)} {/* Web links — one row per port. Each row: [logo 56px] Description or app name ↗ http://IP:PORT Logo is optional (per-port `logo_url`); when absent the row indents naturally to align with the text. If we can't resolve an IP for the CT we hide the row. */} {app.ports && app.ports.length > 0 && (
{app.ports.map((p) => { const url = buildWebUrl(ctIp, p.port, p.scheme) if (!url) return null const label = p.description || app.name return (
{p.logo_url && ( )}
{label} {url}
) })}
)} {/* 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 remains available in edit mode. Buttons match the Settings-page section style (h-8, outline, small icon + label) for visual consistency across the app. */} {editMode && (
{tracking && ( )} {tracking && ( )}
)}
) })} {/* Post-registration "also detected" strip — every hint slug whose install signature is present on the CT AND that hasn't been registered yet is shown as a chip with a one-click Register button. Filtered against the sidecar's `helper_slug` field so a registered app never re-appears. */} {apps.length > 0 && unregisteredDetected.length > 0 && (
{t("vmLxc.appEditor.alsoDetectedContainer")}
{unregisteredDetected.map(renderDetectionChip)}
)} {/* Add-more + Edit toggle. Edit is a global toggle that reveals the per-card action footer (Remove / Check / Edit fields). Add-more is disabled while editing so the two flows don't overlap. Routes through the browse panel if there are hidden detections, so the user gets one-click Restore before hand- typing a custom app. */} {apps.length > 0 && (
)} {apps.length > 0 && detectionNotice && (

{detectionNotice.text}

)} {error && (
{error}
)}
) }